@cryptotaxi247 / netdata-1 / commits / 6d20ccaae

SNMP: add network device licensing monitoring (#22122)

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

Costa Tsaousis committed May 8, 2026 at 00:04 UTC 6d20ccaae786652a01707414bb4225be4579ca34
72 files changed +9994 -272
.agents/skills/project-snmp-profiles-authoring/SKILL.md
+22
@@ -30,6 +30,16 @@ Use this skill before editing files under:
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 +9. Put SNMP licensing rows under top-level `licensing:`. Do not model licensing
34 + telemetry as underscore-prefixed hidden metrics or `_license_*` tag
35 + protocols.
36 +10. Licensing row value symbols may use `format` and exact `mapping`, but must
37 + not use chart/export fields, transforms, scale factors, constant values, or
38 + underscore-prefixed generated names.
39 +11. For scalar licensing rows that combine multiple scalar signal OIDs into one
40 + license row, declare an explicit stable `id:`. For table licensing rows,
41 + keep `from:` references inside the same table OID and derive
42 + `not-accessible` INDEX values from the row index.
43
44 ## Index Rules
45
@@ -103,6 +113,18 @@ When adding a new topology kind, update all three parts together:
113 Verify that topology rows are delivered through `ProfileMetrics.TopologyMetrics`,
114 not through underscore-prefixed `HiddenMetrics`.
115
116 +When adding or migrating licensing profile coverage, update all related parts
117 +together:
118 +
119 +- profile YAML using `licensing:`;
120 +- the closed licensing signal/state/sentinel enums and validation when adding
121 + new policy names;
122 +- typed `ProfileMetrics.LicenseRows` producer/consumer tests;
123 +- MIB evidence for every OID and every `not-accessible` index-derived field.
124 +
125 +Verify that licensing rows are delivered through `ProfileMetrics.LicenseRows`,
126 +not through underscore-prefixed `HiddenMetrics`.
127 +
128 When adding or refactoring SNMP profile, parser, or topology tests, prefer
129 table-driven cases using `map[string]struct{}` keyed by test-case name when
130 the cases share setup and assertion shape. Use separate test functions only for
.agents/sow/done/SOW-0012-20260505-streaming-topology-classification-bugs.md renamed
+13 -9
@@ -2,9 +2,9 @@
2
3 ## Status
4
5 -Status: in-progress
5 +Status: completed
6
7 -Sub-state: triage complete; live cloud evidence captured; root causes located; reviewed by six external assistants in three iterations; user decisions recorded 2026-05-06; implementation in progress; backend topology filters removed from scope by project-owner decision.
7 +Sub-state: completed after PR merge. User confirmed fixes merged in commit `cc50307bc6ca180285a0e0cda16d73ab7a42cd86` (`Fix streaming topology graph output (#22432)`) on 2026-05-07.
8
9 ## Requirements
10
@@ -514,30 +514,34 @@ PR commit order: split → B → A → D → C → doc → tests → validation.
514 - 2026-05-06 — validation so far: focused compiler syntax check of `src/web/api/functions/function-topology-streaming.c` passed using the existing `functions.c` compile flags from `compile_commands.json`. Full `cmake --build build-clion --target netdata -j4` did not reach code compilation because CMake reconfigured and failed while fetching the pre-existing Sentry/crashpad dependency (`mini_chromium` HTTP 400 / expected acknowledgments). This is an environment/dependency fetch failure, not a compiler error from the topology file.
515 - 2026-05-06 — after project owner installed the build locally, queried the installed local agent through bearer-protected direct API without printing tokens or durable identifiers. Sanitized runtime summary: `status=200`, `accepted_params=["info"]`, `actors=22`, `links=22`, actor type counts `child=13`, `parent=2`, `stale=1`, `vnode=6`, link type counts `streaming=15`, `virtual=6`, `stale=1`, duplicate actor ids `0`, duplicate link tuples `0`, vnode stale links `0`.
516 - 2026-05-06 — project owner decision: do not add tests before first PR publication. Open a draft PR now so online reviewers can inspect the code. Tests remain a known validation gap and this SOW remains in progress until follow-up validation is complete or the project owner explicitly accepts closure without tests.
517 +- 2026-05-07 — project owner confirmed the PR with fixes was merged as commit `cc50307bc6ca180285a0e0cda16d73ab7a42cd86` (`Fix streaming topology graph output (#22432)`) and requested moving this SOW to `done/`.
518
519 ## Validation
520
520 -Partial, implementation still in progress.
521 +Completed for SOW close based on merged PR evidence and project-owner acceptance.
522
523 - Focused syntax validation passed for `src/web/api/functions/function-topology-streaming.c` using the existing `functions.c` compile command from `compile_commands.json`, replacing the source path and using `-fsyntax-only`.
524 - Full local build attempted with `cmake --build build-clion --target netdata -j4`. It failed during CMake reconfigure while fetching the pre-existing Sentry/crashpad dependency (`external/crashpad/third_party/mini_chromium/mini_chromium`, HTTP 400 / expected acknowledgments) before compiling Netdata sources.
525 - Same-failure search for removed backend filter identifiers in `function-topology-streaming.c` found no remaining `value_in_csv`, `filter_node_type`, `filter_ingest_status`, `filter_stream_status`, `node_type:`, `ingest_status:`, or `stream_status:` references.
526 - Installed-agent runtime validation via direct local Function call: `status=200`; `accepted_params` contains only `info`; graph contains 22 actors and 22 links; actor/link duplicate checks are zero; vnode stale-link check is zero.
526 -- Tests: deferred by project-owner decision for the first draft PR. This is a known gap for reviewers, not a completed validation item.
527 +- Merge evidence: `git show --stat --oneline cc50307bc6ca180285a0e0cda16d73ab7a42cd86 --` shows commit `cc50307bc Fix streaming topology graph output (#22432)` with the expected function split, topology function changes, `src/streaming/STREAM_PATH.md`, build manifest updates, and this SOW artifact.
528 +- Tests: deferred by project-owner decision for the first draft PR and accepted at merge time. This remains a residual test gap but no longer blocks this SOW.
529
530 ## Outcome
531
530 -Pending.
532 +Completed. PR `#22432` merged as commit `cc50307bc6ca180285a0e0cda16d73ab7a42cd86`, delivering the streaming topology graph fixes, function split, backend filter removal, timestamp column type correction, synthesized upstream parent rendering, and `STREAM_PATH` maintenance documentation.
533
534 ## Lessons Extracted
535
534 -Pending.
536 +- The streaming topology view must treat stored stream paths as hints, not as complete live topology truth for localhost classification.
537 +- Backend graph-pruning filters can make topology responses internally inconsistent when the frontend expects to facet over a complete graph.
538 +- For SOWs that intentionally publish a draft before tests, the residual test gap must be explicit and requires project-owner acceptance before close.
539
540 ## Followup
541
538 -- If the multi-hop / non-localhost classification residual surfaces in real use after this SOW lands, open a follow-up SOW that extends the live-state classification (Decision 1 Option 1) to all hosts (Option 2 in this SOW). **No protocol change is contemplated** — both this SOW and any follow-up are entirely inside the topology function.
539 -- Add a how-to under `docs/netdata-ai/skills/query-netdata-cloud/how-tos/` describing how to call `topology:streaming` and how to interpret actor types and the streaming-path table.
540 -- **Open a separate SOW for streaming-path defensive hardening** in `src/streaming/stream-path.c`: (a) clamp incoming JSON array length to `UINT16_MAX` before `callocz` to prevent uint16_t truncation of `host->stream.path.size`/`used`; (b) add scalar range checks for `hops` (int16_t), `start_time_ms` (uint32_t), `shutdown_time_ms` (uint32_t) against negative inputs from a malformed peer at `stream-path.c:313-317`. Pre-existing defensive gap, not introduced by this work.
542 +- Conditional residual: if the multi-hop / non-localhost classification residual surfaces in real use after this SOW lands, open a follow-up SOW that extends the live-state classification (Decision 1 Option 1) to all hosts (Option 2 in this SOW). No protocol change is contemplated; both this SOW and any follow-up are entirely inside the topology function.
543 +- Query skill how-to: not required for the merged code path and not blocking this SOW close by project-owner acceptance. Track under future `query-netdata-cloud` skill maintenance if the workflow recurs.
544 +- Streaming-path defensive hardening: explicitly out of scope for this SOW, pre-existing, and not introduced by the merged fixes. Track as a future hardening SOW only if prioritized.
545
546 ## Regression Log
547
.agents/sow/done/SOW-0013-20260507-snmp-licensing-projection.md new
+920
@@ -0,0 +1,920 @@
1 +# SOW-0013 - SNMP licensing profile projection
2 +
3 +## Status
4 +
5 +Status: completed
6 +
7 +Sub-state: implementation completed and final review findings closed on 2026-05-07; ready to move to `.agents/sow/done/`.
8 +
9 +## Requirements
10 +
11 +### Purpose
12 +
13 +Replace the WIP SNMP licensing monitoring branch's hidden-metric delivery protocol with a clean typed licensing profile section and projection. Fix concrete profile correctness bugs while migrating to the typed schema, rather than patching the old hidden `_license_row*` protocol.
14 +
15 +### User Request
16 +
17 +User request summary:
18 +
19 +- Analyze the squashed `pr-ktsaou-licensing-monitoring` branch after rebase on `master`.
20 +- Find code smells, hacks, side effects, and profile correctness bugs.
21 +- Prefer a clean end state over low churn or backward compatibility because licensing monitoring is WIP/nightly.
22 +- Treat the current HiddenMetrics licensing path as another instance of the topology anti-pattern that was just removed.
23 +- Use typed projection/schema-driven delivery analogous to the SNMP topology profile projection.
24 +- Fix concrete correctness bugs as part of the projection migration, not as temporary patches to the hidden-metric protocol.
25 +
26 +Detailed design source:
27 +
28 +- `src/go/plugin/go.d/TODO-snmp-licensing-monitoring-review.md` (local-only development file; must not be committed).
29 +
30 +### Assistant Understanding
31 +
32 +Facts:
33 +
34 +- The original WIP branch implemented SNMP licensing by encoding semantic license rows as underscore-prefixed hidden metrics named `_license_row*`.
35 +- `ddsnmpcollector` generically moves underscore-prefixed metrics into `ProfileMetrics.HiddenMetrics`; this remains available for unrelated private metrics.
36 +- The original SNMP collector licensing code consumed `pm.HiddenMetrics`, recognized `_license_row*`, and dispatched by string tag `_license_value_kind`.
37 +- Recent SNMP topology work replaced the same class of metric-name/HiddenMetrics hack with top-level `topology:` profile rows and typed `ProfileMetrics.TopologyMetrics`.
38 +- `ProfileMetrics.HiddenMetrics` must remain a generic delivery container for unrelated underscore-prefixed/private metrics; licensing must stop using it as its semantic transport.
39 +- Downloaded local MIBs are review evidence only and must remain untracked:
40 + - `CISCO-SMART-LIC-MIB.my`
41 + - `CISCO-LICENSE-MGMT-MIB.mib`
42 + - `BLUECOAT-LICENSE-MIB.mib`
43 + - `CHECKPOINT-MIB.mib`
44 +- User decision: keep these raw MIB files at the repository root during implementation, do not commit them, and delete them after implementation is verified.
45 +
46 +Inferences:
47 +
48 +- The root problem is not just a few bad OIDs. The root problem is the absence of a typed licensing contract between SNMP profiles, `ddsnmpcollector`, and the SNMP collector's licensing aggregation.
49 +- Profile correctness bugs should not be used as parity targets for the new design. The typed profile migration should be authored from MIB truth.
50 +- The clean shape likely mirrors topology: top-level `licensing:` rows, closed enums, catalog projection, typed `ProfileMetrics` output, validation, and profile-format documentation.
51 +
52 +Unknowns:
53 +
54 +- No open design decisions remain. The only accepted follow-up is unsupported licensing table-root caching for broad Cisco coverage, tracked by `.agents/sow/pending/SOW-0014-20260507-snmp-licensing-unsupported-table-cache.md`.
55 +
56 +### Acceptance Criteria
57 +
58 +- SNMP licensing profile data is represented by a first-class typed profile section/projection, not `_license_row*` hidden metrics.
59 +- `ProfileMetrics.HiddenMetrics` remains available and tested as a generic non-licensing underscore-prefixed metric delivery container.
60 +- SNMP licensing consumes typed licensing output from `ddsnmpcollector`, not magic metric names or `_license_value_kind` tags.
61 +- Licensing profile schema has closed validation for signal kinds and rejects malformed profile rows at load time.
62 +- Concrete profile correctness bugs are fixed in the migrated schema:
63 + - Cisco Smart scalar rows at the current `_cisco-base.yaml` lines 148, 172, 211, 229, and 247 use scalar instance suffix `.0`; Cisco Smart table rows are not treated as scalars.
64 + - Cisco traditional row identity does not merge distinct rows with the same feature name.
65 + - Check Point licensing OID mapping follows the `svnLicensing` table from the refreshed `CHECKPOINT-MIB.mib`.
66 + - Blue Coat derives `appLicenseStatusIndex` from the row index instead of reading the `not-accessible` object as `symbol.OID`.
67 + - Sophos and MikroTik ignored-state/sentinel behavior is no longer filename-gated.
68 + - Cisco licensing is represented by dedicated typed licensing mixins, not hidden `_license_row*` blocks in `_cisco-base.yaml`; broad Cisco coverage comes from `cisco.yaml` extending those mixins.
69 +- Runtime merge identity uses `OriginProfileID` plus table OID plus an INDEX-derived row key for table rows. `OriginProfileID` is the logical profile file that declared the licensing row, including mixin-origin rows after `extends:` merge. It must not use display table names, stripped filenames, root matched profile names, or absolute source paths as structural identity.
70 +- Eval/trial states do not generate warning/critical alerts by default; they may be exposed as informational function/chart state.
71 +- Licensing health alerts are scoped by their SNMP-specific `snmp.license.*` contexts; extra `chart labels: component=licensing` filters are intentionally not used.
72 +- Workstation-local provenance comments are sanitized before commit, including `bluecoat-proxysg.yaml:47` and path-bearing licensing fixtures under `ddsnmpcollector/testdata/licensing/`.
73 +- Provenance sanitation explicitly covers:
74 + - `config/go.d/snmp.profiles/default/bluecoat-proxysg.yaml:47`
75 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/checkpoint.snmprec:2`
76 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/cisco-smart-iosxe-c9800.snmprec:2`
77 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/cisco-traditional.snmpwalk:2`
78 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/mikrotik-router.snmprec:2-3`
79 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/sophos-xgs-firewall.snmprec:2`
80 +- The four raw MIB files are deleted from repo root before SOW close-out and are never staged or committed.
81 +- The local `TODO-snmp-licensing-monitoring-review.md` file remains out of the PR.
82 +- Tests cover full loaded profiles, not only sliced licensing blocks.
83 +- Profile-format documentation and project SNMP authoring skill describe the new licensing authoring contract.
84 +
85 +## Analysis
86 +
87 +Sources checked:
88 +
89 +- `src/go/plugin/go.d/TODO-snmp-licensing-monitoring-review.md`
90 +- `src/go/plugin/go.d/collector/snmp/licensing.go`
91 +- `src/go/plugin/go.d/collector/snmp/licensing_state.go`
92 +- `src/go/plugin/go.d/collector/snmp/licensing_vendor_sanity.go`
93 +- `src/go/plugin/go.d/collector/snmp/licensing_charts.go`
94 +- `src/go/plugin/go.d/collector/snmp/licensing_integration.go`
95 +- `src/go/plugin/go.d/collector/snmp/func_licenses.go`
96 +- `src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector.go`
97 +- `src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_scalar.go`
98 +- `src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_license_fixtures_test.go`
99 +- `src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/licensing_test_helpers_test.go`
100 +- `src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/*`
101 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/_cisco-base.yaml`
102 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/checkpoint.yaml`
103 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/bluecoat-proxysg.yaml`
104 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/fortinet-fortigate.yaml`
105 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/mikrotik-router.yaml`
106 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/sophos-xgs-firewall.yaml`
107 +- `src/go/plugin/go.d/collector/snmp/profile-format.md`
108 +- `src/go/plugin/go.d/collector/snmp/metadata.yaml`
109 +- `src/health/health.d/snmp.conf`
110 +- `.agents/sow/specs/snmp-profile-projection.md`
111 +- `.agents/sow/specs/sensitive-data-discipline.md`
112 +- `.agents/skills/project-snmp-profiles-authoring/SKILL.md`
113 +- `.agents/skills/project-writing-collectors/SKILL.md`
114 +
115 +Current state:
116 +
117 +- Licensing rows are first-class `licensing:` profile data delivered through typed `ProfileMetrics.LicenseRows`.
118 +- License signal kinds, sentinel policies, state policies, symbol forbid-lists, row identity, and duplicate signals are validated at profile load.
119 +- The `_license_row*` / `_license_value_kind` hidden-metric protocol is removed from production profile YAML and runtime licensing consumption.
120 +- Cisco, Check Point, Blue Coat, Fortinet, MikroTik, and Sophos licensing profiles have been migrated to typed rows with MIB-derived corrections recorded in this SOW.
121 +- Full-profile and fixture-backed tests cover the migrated licensing profile families, while focused unit tests cover schema, projection, aggregation, function, and delivery edge cases.
122 +
123 +Risks:
124 +
125 +- Broad SNMP profile blast radius if migrated profiles break ordinary metric collection.
126 +- Function/UI regression if licensing aggregation output changes without matching docs/metadata/health updates.
127 +- Alert noise if eval/grace states remain mapped to degraded warning semantics.
128 +- Silent data loss if typed schema does not model scalar-only licensing rows and table licensing rows cleanly.
129 +- Sensitive data/provenance risk from committing local MIB files, workstation paths, or unsanitized SNMP fixtures.
130 +
131 +## Pre-Implementation Gate
132 +
133 +Status: ready
134 +
135 +Problem / root-cause model:
136 +
137 +- The licensing feature repeats the old topology design flaw: it encodes semantic non-chart observations as hidden chart metrics plus magic names/tags. The transport is fragile, hard to validate, and easy to silently break. Concrete profile bugs show why the hidden protocol is the wrong parity target; the clean implementation needs a typed licensing schema and output projection built from MIB evidence.
138 +
139 +Evidence reviewed:
140 +
141 +- `collector/snmp/licensing.go` hidden `_license_row*` extraction and `_license_value_kind` dispatch.
142 +- `collector/snmp/ddsnmp/ddsnmpcollector/collector.go` generic underscore-prefix `HiddenMetrics` bucketing.
143 +- `collector/snmp/ddsnmp/metric.go` and `ddsnmpcollector/collector_topology.go` typed topology projection precedent.
144 +- `.agents/sow/specs/snmp-profile-projection.md` current metrics/topology profile projection contract.
145 +- `config/go.d/snmp.profiles/default/_cisco-base.yaml` Cisco Smart scalar object OIDs without `.0`.
146 +- Local `CISCO-SMART-LIC-MIB.my` proving Cisco Smart subtree is valid but scalar instances still need `.0`.
147 +- Local `CISCO-LICENSE-MGMT-MIB.mib` proving Cisco traditional table has three index components.
148 +- Local `CHECKPOINT-MIB.mib` proving `svnLicensing` object order and `licensingIndex MAX-ACCESS read-only`.
149 +- Local `BLUECOAT-LICENSE-MIB.mib` proving `appLicenseStatusIndex MAX-ACCESS not-accessible`.
150 +
151 +Affected contracts and surfaces:
152 +
153 +- SNMP profile schema and profile validation.
154 +- ddsnmp catalog projection consumers.
155 +- ddsnmpcollector `ProfileMetrics` output.
156 +- SNMP collector licensing aggregation, charts, function output, and alert inputs.
157 +- Default SNMP profile YAMLs for Cisco, Check Point, Blue Coat, Fortinet, MikroTik, Sophos.
158 +- SNMP profile-format documentation.
159 +- Runtime project SNMP profile authoring skill.
160 +- Integration metadata and health alerts if chart/function/alert semantics change.
161 +
162 +Existing patterns to reuse:
163 +
164 +- Top-level `topology:` typed profile section and `ProfileMetrics.TopologyMetrics`.
165 +- Closed enum validation from topology kind validation.
166 +- Catalog projection consumer model from `.agents/sow/specs/snmp-profile-projection.md`.
167 +- Existing scalar/table collection machinery from `ddsnmpcollector`.
168 +- Table/index accessibility rules in `.agents/skills/project-snmp-profiles-authoring/SKILL.md`.
169 +- Table-driven Go tests using `map[string]struct{}` where setup/assertion shapes match.
170 +
171 +Risk and blast radius:
172 +
173 +- Production regular SNMP metrics must not regress even though licensing is WIP/nightly.
174 +- Licensing itself can break backward compatibility because it is WIP/nightly, but the end state must be coherent and maintainable.
175 +- HiddenMetrics cannot be removed or redefined globally inside this SOW because it remains a generic private-metric mechanism with canary coverage; after licensing migration, grep currently shows no non-licensing production consumers.
176 +- Profile YAML migration can change SNMP walk/GET load on broad vendor base profiles.
177 +- Health templates and function output are user/operator-facing and require consistency with metadata/docs.
178 +
179 +Sensitive data handling plan:
180 +
181 +- Do not commit downloaded raw MIB files.
182 +- Do not commit this local TODO file.
183 +- Do not commit SNMP communities, SNMPv3 credentials, bearer tokens, customer hostnames, customer sysName/sysDescr, customer IPs, customer names, or personal data.
184 +- Replace workstation-local provenance paths with upstream repository/commit/relative-path evidence where public, or sanitized descriptions where not.
185 +- Any new fixtures must use sanitized public/vendor-derived values only.
186 +
187 +Implementation plan:
188 +
189 +- See `## Plan` below. That top-level plan is the canonical execution order and starts with validation/test scaffolding before schema/API work.
190 +
191 +Validation plan:
192 +
193 +- Strict fixture GET helper that fails on unexpected missing OIDs.
194 +- Duplicate-detecting license row/signal assertions.
195 +- Full-profile smoke tests for all licensing-bearing profile families.
196 +- Negative schema validation tests for invalid licensing signal kinds/fields.
197 +- Cisco Smart scalar `.0` collection test.
198 +- Check Point corrected object-mapping test.
199 +- Blue Coat index-derived identity test.
200 +- Sentinel/ignored-state tests independent of profile filename.
201 +- Sentinel parity test proving policies apply to both timestamp and remaining-value signals.
202 +- Sophos state test proving ignored raw-state hints beat severity `0` according to the selected state policy.
203 +- Sophos static `_license_id` uniqueness test covering the copy-pasted Sophos licensing blocks.
204 +- Chart registration matrix tests for partial signal availability.
205 +- Health alert tests if alerts remain in scope.
206 +- Cold-start function test proving the current 503-before-first-collect behavior is either preserved or deliberately changed by a recorded decision.
207 +- Cross-extends Cisco mixin dedup test.
208 +- `Project(licensing)` metric-tag propagation positive and negative tests.
209 +- Narrow Go test suites for `collector/snmp/ddsnmp/...` and `collector/snmp/...`.
210 +
211 +Artifact impact plan:
212 +
213 +- AGENTS.md: no expected update; existing process and collector rules already apply.
214 +- Runtime project skills: update `.agents/skills/project-snmp-profiles-authoring/SKILL.md` if licensing authoring rules are added.
215 +- Specs: update `.agents/sow/specs/snmp-profile-projection.md` or add a licensing-specific spec after implementation defines the durable contract. The spec update must capture the licensing consumer, projection rules, inheritance/merge rules, signal/policy enums, and local-only MIB evidence policy.
216 +- End-user/operator docs: update `collector/snmp/profile-format.md`, metadata/docs/health references if chart/function/alert semantics change.
217 +- End-user/operator skills: no expected update unless public Netdata AI skills reference SNMP licensing profile schema.
218 +- SOW lifecycle: decisions are resolved; move to current/in-progress only when implementation begins.
219 +
220 +Open-source reference evidence:
221 +
222 +- Existing branch artifacts cite workstation-local mirrored-OSS paths and must be sanitized before commit:
223 + - `config/go.d/snmp.profiles/default/bluecoat-proxysg.yaml:47`
224 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/checkpoint.snmprec:2`
225 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/cisco-smart-iosxe-c9800.snmprec:2`
226 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/cisco-traditional.snmpwalk:2`
227 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/mikrotik-router.snmprec:2-3`
228 + - `collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/sophos-xgs-firewall.snmprec:2`
229 +- The four downloaded MIB files and the local TODO are currently untracked at the repository root / working directory and are not ignored. User decision: keep the MIB files at repo root during implementation, do not commit them, and delete them after implementation is verified. Keep this local TODO out of commits.
230 +
231 +Open decisions:
232 +
233 +- None. User selected: `2A 3A 4A 5C 6B 7B 8B 9B 10A 11A 12B 13A 14A 15A 17A 18A 19A 20A 21A 22A`, plus profile-origin identity option `1B`.
234 +- Post-slice review decision: regular SNMP uses an explicit combined metrics+licensing projection, not ad hoc reattachment and not a second collector pass. This keeps single-consumer projections pure while allowing the regular SNMP collector to collect both chart metrics and typed licensing rows in one `ddsnmpcollector` pass.
235 +- Follow-up projection API decision: use variadic `Project(consumer, consumers...)` for combined projections instead of a one-off `ProjectMetricsAndLicensing()` helper. Existing single-consumer callers stay unchanged, and regular SNMP calls `Project(metrics, licensing)`.
236 +
237 +## Cross-Cutting Resolution Rules
238 +
239 +These rules combine the resolved decisions above and prevent implementation drift.
240 +
241 +1. Licensing structural identity uses a real origin profile id, not a stripped filename and not the root matched profile. Add an `OriginProfileID` field to the resolved profile / `ProfileMetrics` / typed licensing row path and use it for licensing identity. The value identifies the logical profile file that declared the licensing row, including mixin-origin rows after `extends:` merge; it must not expose absolute workstation paths as user-facing license source. Typed `LicenseRow` carries the table OID directly from the licensing producer; do not add table OID to generic `ddsnmp.Metric` solely for licensing.
242 +2. Scalar identity defaults to `(origin-profile-id, scalar-symbol-OID)`. Profiles using explicit scalar grouping to aggregate multiple scalar OIDs into one row use `(origin-profile-id, licensing group id)` for that grouped row; grouped member signals must not also produce standalone scalar rows.
243 +3. Sentinel policies are evaluated at typed licensing projection emit time. A sentinel-rejected value sets the target signal's `Has` flag to false, so SNMP licensing aggregation never sees sentinel values.
244 +4. Bucket evaluation order is: ignored raw-state policy suppresses all other buckets; non-ignored hard failure conditions such as expired timers and exhausted usage produce broken; eval/trial-like states produce informational; grace/degraded states produce degraded; otherwise normal valid signals produce healthy or ignored according to the selected state policy.
245 +5. Top-level metric tags propagate to `Project(licensing)` when `consumers:` is unset, matching topology semantics. The licensing-row forbid-list applies only to licensing row value symbols, not to top-level tags.
246 +6. Repeated-signal load errors apply to duplicate `(structural identity, signal kind)` inside one resolved profile after inheritance merge. Table-row and scalar-row identities are different shapes and are never duplicates by accident, so a Cisco profile extending both Cisco licensing mixins is valid.
247 +7. `from: <oid>` sibling references are expanded by the typed licensing producer at collection time. For table rows, `from` must refer to a peer in the same SNMP table row; for scalar rows, `from` must refer to another scalar in the same profile or explicit scalar group. Cross-profile `from` is a validation error.
248 +8. Sophos-style sibling date migration uses this canonical shape:
249 +
250 + ```yaml
251 + licensing:
252 + - id: sophos-base-firewall
253 + identity:
254 + id: { value: base_firewall }
255 + name: { value: Base Firewall }
256 + state:
257 + from: 1.3.6.1.4.1...
258 + mapping: { 0: ignored, 1: healthy, 2: degraded, 3: broken }
259 + signals:
260 + expiry:
261 + from: 1.3.6.1.4.1...
262 + format: text_date
263 + sentinel: [timer_zero_or_negative]
264 + ```
265 +
266 +9. Regular SNMP profile setup uses `Project(metrics, licensing)`. `Project(metrics)` remains metrics-only, `Project(licensing)` remains licensing-only, and the variadic projection keeps the typed licensing producer reachable without a duplicate SNMP pass.
267 +10. Mixed projections use the public variadic `Project(consumer, consumers...)` API. This keeps the single-consumer API stable while avoiding one-off helpers for every valid consumer combination.
268 +
269 +## Implications And Decisions
270 +
271 +### Decision 1 - Licensing delivery contract
272 +
273 +Status: resolved by user.
274 +
275 +Selected option: A. First-class typed licensing profile section/projection.
276 +
277 +Rejected alternatives:
278 +
279 +- Keep `_license_row*` hidden metrics and add validation. Rejected because it preserves the same technical debt that topology just removed.
280 +- Model licenses as normal exported profile metrics. Rejected because license rows are semantic aggregation inputs, not chart metrics.
281 +
282 +### Decision 2 - YAML section key name
283 +
284 +Status: resolved by user: 2A.
285 +
286 +Options:
287 +
288 +- A. `licensing:`
289 + - Pros: matches singular top-level `topology:` precedent; describes the feature domain rather than individual row count.
290 + - Cons: less literal than `licenses:` for a list.
291 +- B. `licenses:`
292 + - Pros: literal list name.
293 + - Cons: diverges from `topology:` naming style and reads more like a user-facing entity list than a profile capability section.
294 +
295 +Recommendation: A. Use top-level `licensing:`.
296 +
297 +Selected option: A. Use top-level `licensing:`.
298 +
299 +### Decision 3 - YAML schema shape
300 +
301 +Status: resolved by user: 3A.
302 +
303 +Options:
304 +
305 +- A. Row-centric top-level `licensing:` blocks.
306 + - Each block declares table/scalar source, identity/descriptors/state, and typed signals.
307 + - Pros: mirrors how users think about one license row; keeps identity and signals together; suitable for table-based vendors.
308 + - Cons: scalar-only vendors need a row wrapper.
309 +- B. Signal-centric top-level `licensing:` blocks.
310 + - Each block declares one signal and separate grouping metadata.
311 + - Pros: close to the current hidden-metric implementation.
312 + - Cons: repeats row reconstruction complexity; easier to mis-group.
313 +- C. Separate top-level `licenses:` identity blocks plus `license_signals:` signal blocks.
314 + - Pros: highly explicit.
315 + - Cons: verbose, more merge/linkage validation, more authoring friction.
316 +
317 +Recommendation: A. Row-centric `licensing:` blocks are the cleanest end state and best match the topology top-level-section precedent. The schema must explicitly support scalar-only rows without forcing authors to invent a synthetic table wrapper.
318 +
319 +Selected option: A. Row-centric top-level `licensing:` blocks.
320 +
321 +### Decision 4 - Runtime typed output shape
322 +
323 +Status: resolved by user: 4A.
324 +
325 +Options:
326 +
327 +- A. `ProfileMetrics.LicenseRows []LicenseRow`, where each row has identity/descriptors plus typed grouped signal structs:
328 + - `State LicenseState`
329 + - `Expiry LicenseTimer`
330 + - `Authorization LicenseTimer`
331 + - `Certificate LicenseTimer`
332 + - `Grace LicenseTimer`
333 + - `Usage LicenseUsage`
334 + - each grouped struct carries an explicit `Has` boolean rather than using pointers.
335 +- B. `ProfileMetrics.LicenseRows []LicenseRow`, where each row has `Signals map[LicenseSignalKind]LicenseSignal`.
336 +- C. Separate typed slices such as `LicenseTimers`, `LicenseUsage`, `LicenseStates`.
337 +
338 +Recommendation: A. Licensing signals are heterogeneous. Timer, usage, and state values have different shapes, so a uniform `map[LicenseSignalKind]LicenseSignal` either becomes a tagged union or a wide optional-field struct. Keep `LicenseSignalKind` as the closed schema/validation enum, but make runtime rows compile-time clear with grouped structs. Use by-value grouped structs plus `Has` flags so `LicenseRows` can be shallow-cloned like the current cache without pointer aliasing surprises.
339 +
340 +Selected option: A. `ProfileMetrics.LicenseRows []LicenseRow` with by-value grouped signal structs plus `Has` flags.
341 +
342 +### Decision 5 - Runtime row identity and display grouping
343 +
344 +Status: resolved by user: 5C.
345 +
346 +Options:
347 +
348 +- A. Structural identity only: origin profile id + table OID + INDEX-derived row key for table rows; origin profile id + scalar OID/block id for scalar rows.
349 +- B. Semantic identity: vendor-provided license ID/name/feature only.
350 +- C. Hybrid: structural identity for collection/dedup, semantic identity for display/grouping.
351 +
352 +Recommendation: C. Structural identity prevents Cisco-style feature-name collisions; semantic identity remains useful for UI/function display. Structural identity must use table OID, not display table name, and must not use stripped filenames or the root matched profile as the source identity. Add a real `OriginProfileID` field to the resolved profile / `ProfileMetrics` / typed licensing row path for licensing structural identity. `OriginProfileID` is the logical profile file that declared the licensing row, including mixin-origin rows after `extends:` merge. Typed `LicenseRow` carries the table OID directly from the licensing producer; do not add table OID to generic `ddsnmp.Metric` solely for licensing.
353 +
354 +Selected option: C. Structural identity for collection/dedup; semantic identity for display/grouping.
355 +
356 +### Decision 6 - Numeric sentinel policy
357 +
358 +Status: resolved by user: 6B.
359 +
360 +Options:
361 +
362 +- A. Fully declarative YAML sentinel rules.
363 + - Pros: maximum flexibility.
364 + - Cons: turns profile YAML into a rule language and is hard to validate cleanly.
365 +- B. Closed built-in sentinel policy names referenced from YAML.
366 + - Initial set: `timer_zero_or_negative`, `timer_u32_max`, `timer_pre_1971`.
367 + - Pros: validates cleanly; removes filename gates; keeps YAML readable.
368 + - Cons: new sentinel patterns require code/schema updates.
369 +- C. Runtime vendor-specific code keyed by profile filename/source.
370 + - Pros: fastest to patch.
371 + - Cons: repeats current MikroTik filename-gated behavior.
372 +
373 +Recommendation: B. Use closed built-in sentinel policies referenced from typed signal config. Sentinel policies attach per signal field and apply to both absolute timestamp signals and remaining-value signals; do not repeat today's timestamp-only sentinel asymmetry.
374 +
375 +Selected option: B. Closed built-in sentinel policy names referenced from YAML.
376 +
377 +### Decision 7 - Raw-state classification policy
378 +
379 +Status: resolved by user: 7B.
380 +
381 +Options:
382 +
383 +- A. Fully declarative YAML raw-state match rules.
384 + - Pros: vendor-specific states can be modeled without code changes.
385 + - Cons: another profile rule language; easy to make matching inconsistent across vendors.
386 +- B. Closed built-in state policy names referenced from YAML, with profile-provided severity mappings allowed.
387 + - Pros: validates cleanly and centralizes bucket semantics.
388 + - Cons: new policy classes require code/schema updates.
389 +- C. Runtime vendor-specific code keyed by profile filename/source.
390 + - Pros: quick for one-off vendors.
391 + - Cons: repeats current filename/source gates.
392 +
393 +Recommendation: B. State classification is string/bucket policy, distinct from numeric sentinel handling. The initial rule must state that ignored raw-state hints win over severity `0` so Sophos `none` / `not_subscribed` rows do not become fake healthy licenses. Prefer modeling this in the typed YAML by not mapping ignored vendor states to severity `0`; if a profile still supplies both raw ignored state and severity `0`, runtime suppression must choose ignored.
394 +
395 +Selected option: B. Closed built-in state policy names referenced from YAML, with ignored raw-state hints winning over severity `0`.
396 +
397 +### Decision 8 - Chart lifecycle
398 +
399 +Status: resolved by user: 8B.
400 +
401 +Options:
402 +
403 +- A. Register fixed licensing chart set and rely on gaps.
404 +- B. Register charts lazily based on observed signal classes.
405 +- C. Fixed charts but split eval/grace/informational states away from degraded alerts.
406 +
407 +Recommendation: B. Lazy charts match the docs' conditional-language intent and avoid empty chart clutter. Lazy registration must guard per chart id, not by checking only the first licensing chart id.
408 +
409 +Selected option: B. Register charts lazily based on observed signal classes.
410 +
411 +### Decision 9 - Alert policy and health scoping
412 +
413 +Status: resolved by user: 9B.
414 +
415 +Options:
416 +
417 +- A. Keep current broad alert behavior.
418 + - Pros: no alert rewrite.
419 + - Cons: eval/trial/grace can alert too aggressively.
420 +- B. Explicit alert table scoped by SNMP-specific licensing contexts.
421 + - Policy: eval/trial -> informational only, no alert; grace -> WARN with delay; degraded -> WARN; broken/expired -> CRIT; usage pressure remains WARN/CRIT by percentage thresholds.
422 + - Health templates target `snmp.license.*` contexts directly; additional chart-label filters are redundant for these SNMP-specific contexts.
423 + - Pros: operator intent is explicit and avoids noisy eval/trial alerts.
424 + - Cons: requires health-template review and tests.
425 +- C. No default alerts for WIP licensing.
426 + - Pros: avoids false positives while the feature matures.
427 + - Cons: less useful out of the box.
428 +
429 +Recommendation: B. Licensing contexts are already SNMP-specific, so direct `on: snmp.license.*` health templates are sufficient. This decision depends on Decision 19 for the eval/trial/grace bucket model.
430 +
431 +Selected option: B. Explicit alert table scoped by SNMP-specific licensing contexts. User later confirmed that alert `chart labels: component=licensing` filters are redundant and should not be used.
432 +
433 +### Decision 10 - Catalog projection consumer
434 +
435 +Status: resolved by user: 10A.
436 +
437 +Options:
438 +
439 +- A. Add `ConsumerLicensing` and `Project(licensing)`.
440 + - Projection keeps `licensing:` rows, drops regular `metrics:`, drops `topology:`, and drops `virtual_metrics`.
441 + - Metadata and top-level tag behavior must be explicit for licensing.
442 + - Pros: matches topology projection design; prevents accidental metrics/topology leakage.
443 + - Cons: requires extending consumer validation and projection tests.
444 +- B. Reuse metrics projection and filter licensing later.
445 + - Pros: less projection work.
446 + - Cons: repeats hidden coupling and tag leakage risk.
447 +
448 +Recommendation: A. Add a first-class `licensing` consumer. `Project(licensing)` drops `metrics:`, `topology:`, and `virtual_metrics`. Metadata and top-level metric tags with no explicit `consumers:` should propagate to licensing only when they are device/profile identity; licensing-specific projection tests must cover positive and negative tag propagation.
449 +
450 +Selected option: A. Add `ConsumerLicensing` and `Project(licensing)`.
451 +
452 +### Decision 11 - Profile inheritance/extends merge
453 +
454 +Status: resolved by user: 11A.
455 +
456 +Options:
457 +
458 +- A. Merge licensing rows by structural identity and derived profile rows override inherited rows.
459 +- B. Append all licensing rows and deduplicate only at runtime.
460 +- C. Reject duplicate licensing structural identities at load time.
461 +
462 +Recommendation: A with load errors for conflicting incompatible definitions. Use table OID plus INDEX-derived row key for table rows; use scalar OID/block id for scalar rows. Cross-profile dedup runs after profile matching, mirroring topology projection semantics.
463 +
464 +Selected option: A. Merge by structural identity with derived override and load errors for incompatible conflicts.
465 +
466 +### Decision 12 - Cisco licensing inheritance scoping
467 +
468 +Status: resolved by user: 12B.
469 +
470 +Options:
471 +
472 +- A. Keep Cisco licensing in `_cisco-base.yaml`.
473 + - Pros: every Cisco profile gets licensing automatically.
474 + - Cons: broad walk/GET blast radius across many Cisco profiles, including devices that may not support the licensing MIBs.
475 +- B. Move Cisco licensing to explicit Cisco licensing mixin(s), and extend only profiles with evidence/fixtures.
476 + - Pros: clean scoping and lower SNMP cost; makes licensing support explicit.
477 + - Cons: requires deciding which Cisco profiles opt in.
478 +- C. Keep in base but add runtime/sysObjectID capability gates.
479 + - Pros: centralized.
480 + - Cons: profile system does not currently express per-row sysObjectID gates cleanly.
481 +
482 +Recommendation: B. Licensing is WIP and clean scoping matters more than low churn. Name the mixins `_cisco-licensing-traditional.yaml` and `_cisco-licensing-smart.yaml`; per-profile opt-in requires MIB evidence or fixture coverage.
483 +
484 +Selected option: B. Move Cisco licensing to explicit Cisco licensing mixin(s).
485 +
486 +### Decision 13 - `licenseDateFromTag` disposition
487 +
488 +Status: resolved by user: 13A.
489 +
490 +Options:
491 +
492 +- A. Delete `licenseDateFromTag` during migration; parse text dates through typed licensing signal config or existing `format: text_date` on fresh symbol values.
493 +- B. Generalize it as a non-licensing `text_date` transform helper.
494 +- C. Keep it for compatibility with old hidden licensing rows.
495 +
496 +Recommendation: A. It is licensing-domain logic inside generic transform machinery and should not survive the hidden-protocol removal. This requires Decision 18 so Sophos-style sibling-OID date values can be declared directly in the typed schema.
497 +
498 +Selected option: A. Delete `licenseDateFromTag` during migration.
499 +
500 +### Decision 14 - Function/chart/health unit contract
501 +
502 +Status: resolved by user: 14A.
503 +
504 +Options:
505 +
506 +- A. Keep separate units per surface and document them: chart metrics in seconds, Function duration fields in milliseconds, health alerts convert chart seconds to days for display.
507 +- B. Normalize every surface to seconds.
508 +- C. Normalize every surface to milliseconds.
509 +
510 +Recommendation: A. It matches current Netdata function duration conventions while keeping chart metrics simple for health calculations. Conversion ownership: charts store raw seconds, health divides chart seconds by 86400 for day display, and the Function multiplies durations by 1000 for millisecond `FieldTransformDuration` cells.
511 +
512 +Selected option: A. Keep per-surface units and document conversion ownership.
513 +
514 +### Decision 15 - `HiddenMetrics` post-migration status
515 +
516 +Status: resolved by user: 15A.
517 +
518 +Options:
519 +
520 +- A. Preserve `HiddenMetrics` as a generic underscore/private-metric mechanism and keep its non-licensing canary tests.
521 +- B. Remove `HiddenMetrics` entirely after licensing migration if no production non-licensing consumers remain.
522 +- C. Keep producer only, but document it as deprecated and unused.
523 +
524 +Recommendation: A for this SOW. Existing tests intentionally cover `_privateMetric` preservation. After licensing migrates, current grep evidence shows no non-licensing production consumers; the canary test is the reason to preserve the generic producer until a separate audit decides removal.
525 +
526 +Selected option: A. Preserve `HiddenMetrics` as a generic underscore/private-metric mechanism for this SOW.
527 +
528 +### Decision 16 - MIB/source evidence policy
529 +
530 +Status: resolved by user: 16A.
531 +
532 +Selected option: A. Keep raw MIBs local-only, cite only sanitized object names/OIDs in SOW/docs.
533 +
534 +Follow-through requirement: sanitize committed workstation-local provenance comments, including `bluecoat-proxysg.yaml:47`.
535 +
536 +### Decision 17 - Scalar row structural identity
537 +
538 +Status: resolved by user: 17A.
539 +
540 +Options:
541 +
542 +- A. Scalar identity is `(origin-profile-id, scalar-symbol-OID)`.
543 + - Pros: prevents unrelated scalar license blocks from collapsing when they share a semantic id; simple to validate.
544 + - Cons: scalar rows that intentionally compose multiple OIDs into one license need an explicit row/group id.
545 +- B. Scalar identity is `(origin-profile-id, semantic license id)`.
546 + - Pros: easy for Cisco Smart-style scalar groups.
547 + - Cons: repeats current collision risk across unrelated scalar MIB objects.
548 +- C. Scalar rows require an explicit structural `id:` in YAML.
549 + - Pros: author-controlled grouping.
550 + - Cons: easy to make unstable or semantic by accident.
551 +
552 +Recommendation: A, with an explicit grouping field only for scalar blocks that intentionally aggregate multiple scalar OIDs into one semantic license row. This depends on the `OriginProfileID` field selected by Decision 5; do not use stripped filenames or root matched profile names as scalar identity.
553 +
554 +Selected option: A. Scalar identity is `(origin-profile-id, scalar-symbol-OID)`, with explicit grouping for intentional multi-scalar rows.
555 +
556 +### Decision 18 - Sibling-OID signal decoding
557 +
558 +Status: resolved by user: 18A.
559 +
560 +Options:
561 +
562 +- A. Allow typed signals to declare `from: <oid>` plus optional `format:` so a signal can decode a sibling scalar/table OID directly.
563 + - Pros: removes `licenseDateFromTag`; supports Sophos-style sibling expiry values cleanly; keeps date parsing in typed licensing collection.
564 + - Cons: schema and collector need explicit source/reference validation.
565 +- B. Require every signal value to be the row anchor symbol value.
566 + - Pros: simpler collector.
567 + - Cons: blocks deleting `licenseDateFromTag` for current Sophos profiles or forces awkward profile duplication.
568 +- C. Keep a generic transform/helper path for sibling values.
569 + - Pros: lower schema work.
570 + - Cons: preserves the transform side channel this migration is meant to delete.
571 +
572 +Recommendation: A. Typed `from:` references are the clean schema replacement for Sophos-style sibling-OID decoding. The typed licensing producer expands `from` at collection time. For table rows, `from` must refer to a peer value in the same SNMP table row; for scalar rows, `from` must refer to another scalar in the same profile or explicit scalar group. Cross-profile `from` is a validation error.
573 +
574 +Selected option: A. Allow typed signals to declare `from: <oid>` plus optional `format:`.
575 +
576 +### Decision 19 - Eval/trial/grace bucket model
577 +
578 +Status: resolved by user: 19A.
579 +
580 +Options:
581 +
582 +- A. Add an informational bucket/dimension. Eval/trial/waiting/initialized states map to informational; grace remains degraded and alerts with delay.
583 + - Pros: preserves visibility without warning on normal eval/trial states; matches the selected alert policy.
584 + - Cons: changes chart dimensions and metadata/health docs.
585 +- B. Map eval/trial states to ignored.
586 + - Pros: no new bucket.
587 + - Cons: hides useful licensing state and overloads ignored semantics.
588 +- C. Keep eval/trial as degraded but suppress alerts by expression.
589 + - Pros: less aggregation change.
590 + - Cons: charts still look degraded and health logic becomes more complex.
591 +
592 +Recommendation: A. Add an informational bucket/dimension and keep actionable grace/degraded/broken separate. Move `evaluation`, `eval`, `trial`, `evaluation_subscription`, and `evaluation_period` out of degraded hints into a new informational hint set.
593 +
594 +Selected option: A. Add an informational bucket/dimension.
595 +
596 +### Decision 20 - Licensing-row validation forbid-list
597 +
598 +Status: resolved by user: 20A.
599 +
600 +Options:
601 +
602 +- A. Licensing row value symbols allow `format` and `mapping`, but reject chart/export fields and transforms: `chart_meta`, `metric_type`, `transform`, `scale_factor`, `constant_value_one`, and underscore-prefixed generated names.
603 + - Pros: keeps useful SNMP decoding (`format`, simple mappings) while blocking chart-only and side-channel behavior.
604 + - Cons: requires licensing-specific validation paths rather than reusing topology's forbid-list exactly.
605 +- B. Reuse topology's stricter forbid-list.
606 + - Pros: simpler validation.
607 + - Cons: incorrectly forbids `format` and `mapping`, which licensing needs for dates and state decoding.
608 +- C. Allow all `SymbolConfig` fields.
609 + - Pros: flexible.
610 + - Cons: repeats hidden protocol mistakes via transforms/chart-only fields.
611 +
612 +Recommendation: A.
613 +
614 +Selected option: A. Allow `format` and `mapping`; reject chart/export fields, transforms, scale/constant hacks, and underscore-generated names.
615 +
616 +### Decision 21 - Repeated-signal conflict semantics
617 +
618 +Status: resolved by user: 21A.
619 +
620 +Options:
621 +
622 +- A. Same structural identity + same signal kind is a load-time error unless an extending profile overrides the inherited definition through the merge rules.
623 + - Pros: prevents silent first-wins/last-wins data loss.
624 + - Cons: stricter authoring.
625 +- B. Derived wins and same-file duplicates are allowed last-wins.
626 + - Pros: flexible.
627 + - Cons: hides copy-paste mistakes.
628 +- C. First wins, matching current runtime behavior.
629 + - Pros: easiest migration from current code.
630 + - Cons: preserves today's silent duplicate loss.
631 +
632 +Recommendation: A.
633 +
634 +Selected option: A. Same structural identity plus same signal kind is a load-time error unless handled by valid extends override semantics.
635 +
636 +### Decision 22 - Function RequiredParams policy
637 +
638 +Status: resolved by user: 22A.
639 +
640 +Options:
641 +
642 +- A. Add explicit `RequiredParams` for `snmp:licenses`, matching the stricter Function interface style used by related functions.
643 + - Pros: clearer function contract and consistency with `interfaces`.
644 + - Cons: requires checking any caller assumptions.
645 +- B. Keep no required params.
646 + - Pros: preserves current Function shape.
647 + - Cons: leaves the contract looser than adjacent functions.
648 +
649 +Recommendation: A, unless caller review shows `snmp:licenses` is intentionally parameterless. Caller review found `snmp:licenses` is intentionally parameterless: it returns the complete per-device license table, and unlike `interfaces`, there is no natural required filter. Implementation should keep `RequiredParams` empty and add a short code comment plus a test asserting the method config is intentionally parameterless.
650 +
651 +Selected option: A with caller-review exception. `snmp:licenses` remains intentionally parameterless; document and test that contract.
652 +
653 +## Plan
654 +
655 +1. Move SOW to `.agents/sow/current/` and mark `Status: in-progress`.
656 +2. Add validation/test scaffolding first: strict GET helper, duplicate-detecting helpers, cold-start Function test, sentinel parity tests, projection tag-propagation tests, and full-profile smoke harness.
657 +3. Add schema/API types and validation for typed licensing.
658 +4. Add typed collector output and tests while preserving generic HiddenMetrics.
659 +5. Rewrite SNMP licensing aggregation to consume typed `ProfileMetrics.LicenseRows` while legacy hidden rows remain present.
660 +6. Re-author profiles from MIB truth and add full-profile tests.
661 +7. Delete hidden licensing protocol artifacts.
662 +8. Before final commit/close-out, delete the four raw MIB files from repo root and keep the local TODO out of the PR.
663 +9. Validate, run reviewer pass, update artifacts/specs/skills, and close SOW.
664 +
665 +## Execution Log
666 +
667 +### 2026-05-07
668 +
669 +- Created pending SOW from the completed licensing branch review.
670 +- Recorded accepted typed-projection direction and open implementation decisions.
671 +- No source code implementation started.
672 +- Folded in Claude's pending-decision review:
673 + - accepted row-centric YAML direction;
674 + - replaced runtime signal-map recommendation with typed grouped sub-struct recommendation;
675 + - split sentinel and raw-state policies;
676 + - added missing decisions for projection consumer, inheritance, Cisco scoping, transform helper disposition, units, health scoping, and HiddenMetrics status;
677 + - recorded MIB evidence policy as resolved 16A.
678 +- Folded in second Claude readiness review:
679 + - accepted all 16 decisions with tightenings;
680 + - added scalar row identity, sibling-OID decoding, eval/trial/grace bucket model, licensing validation forbid-list, repeated-signal conflict semantics, and Function RequiredParams decisions;
681 + - fixed decision numbering to use SOW decision ids consistently;
682 + - recorded workstation-path sanitation and local MIB/TODO hygiene requirements;
683 + - tightened Cisco Smart scalar `.0`, sentinel parity, per-chart lazy guards, SNMP-specific health context scoping, metric-tag projection, Cisco mixin names, unit conversion ownership, and HiddenMetrics preservation facts.
684 +- Recorded user decision bundle: `2A 3A 4A 5C 6B 7B 8B 9B 10A 11A 12B 13A 14A 15A 17A 18A 19A 20A 21A 22A`.
685 +- Recorded local MIB handling decision: keep raw MIBs at repo root during implementation, do not commit them, delete them after implementation is verified.
686 +- Recorded user decision `1B`: licensing structural identity uses `OriginProfileID`, the logical profile file that declared the licensing row, including mixin-origin rows after `extends:` merge.
687 +- Recorded post-slice review decision `combined metrics+licensing projection`: regular SNMP profile setup uses `Project(ConsumerMetrics, ConsumerLicensing)` so `licensing:` rows are delivered to `ddsnmpcollector` in the same pass as ordinary metrics; single-consumer projections remain pure.
688 +- Recorded user decision `projection API option A`: replace the one-off combined projection helper with variadic `Project(consumer, consumers...)`; regular SNMP will call `Project(ConsumerMetrics, ConsumerLicensing)`.
689 +- Activated this SOW by moving it from `.agents/sow/pending/` to `.agents/sow/current/` and setting `Status: in-progress`.
690 +- Phase 1 scaffolding started:
691 + - replaced the lenient fixture GET helper with `mustExpectSNMPGetFromFixture`, which fails immediately when a test asks for an OID absent from the fixture;
692 + - added duplicate-detecting license grouping helpers for `(license id, signal kind)` and Sophos state/expiry fixture grouping, so tests fail on silent overwrite instead of hiding duplicates;
693 + - kept the Cisco Smart entitlement-only fixture scoped to the entitlement table because the public fixture explicitly lacks Smart Licensing scalar registration/auth/certificate timers.
694 +- Validation note: strict fixture coverage exposed Cisco Smart scalar fixture coverage gaps. A later reviewer also found the broad ddsnmpcollector `Version()` failures were caused by licensing test helper leakage of inherited topology rows, not pre-existing branch behavior; the helper must clear `Topology` and `Licensing` for legacy hidden-protocol tests.
695 +- Phase 2 schema/API skeleton started:
696 + - added top-level `licensing:` profile definition storage with clone support;
697 + - added `ConsumerLicensing` to the closed consumer enum and projection path;
698 + - added closed licensing signal kind, sentinel policy, and state policy enums;
699 + - added typed runtime `ProfileMetrics.LicenseRows []LicenseRow` with grouped state/timer/usage structs;
700 + - added licensing validation for invalid policy names, invalid signal kinds, and forbidden licensing value-symbol fields while allowing `format` and `mapping`.
701 +- Added projection coverage proving `Project(licensing)` keeps `licensing:` rows and licensing-selected metadata/metric tags while dropping metrics, topology rows, and virtual metrics.
702 +- Added `OriginProfileID` propagation:
703 + - profile loading assigns a relative logical origin path to each directly declared `licensing:` row;
704 + - `extends:` merge appends base licensing rows while preserving their original declaring profile id;
705 + - added a merge test proving a row declared in `device.yaml` keeps `device.yaml` and an inherited row declared in `_licensing.yaml` keeps `_licensing.yaml`.
706 +- Phase 4 typed collector output started:
707 + - added a typed licensing producer that emits `ProfileMetrics.LicenseRows` from top-level `licensing:` rows;
708 + - scalar licensing rows use SNMP GETs and table licensing rows use SNMP walks while preserving `HiddenMetrics` as a separate generic underscore-prefixed metric container;
709 + - table licensing rows reuse the existing table structure cache path after the first walk, so the typed projection does not introduce a permanent per-cycle table walk;
710 + - table licensing rows carry `OriginProfileID`, table OID, raw row index, structural id, identity/descriptors, state, timer, usage, static tags, and row metric tags;
711 + - scalar licensing rows support explicit group ids, literal values, sibling `from:` OIDs, text-date formatting, mappings, and sentinel filtering at emit time.
712 +- Folded in post-slice readiness review fixes before starting the consumer rewrite:
713 + - wired regular SNMP setup to `Project(ConsumerMetrics, ConsumerLicensing)` so typed `LicenseRows` are reachable in production without a second SNMP pass;
714 + - fixed the legacy licensing test helper to clear inherited `Topology` and `Licensing` rows, which removed the extra topology `Version()` calls in Cisco/MikroTik hidden-protocol tests;
715 + - extended licensing validation for repeated `(structural identity, signal kind)` rows, table `from:` scope, scalar multi-signal grouping, descriptor-only rows, scalar index misuse, underscore-prefixed legacy names, and closed value formats;
716 + - applied sentinel filtering to usage signals too, not only timer signals;
717 + - kept licensing table walk failures from poisoning the generic metric `missingOIDs` cache;
718 + - added `Stats.Metrics.Licensing` so typed licensing rows are counted separately from ordinary metric rows.
719 +- Replaced the temporary combined-projection helper shape with the resolved variadic `Project(consumer, consumers...)` API; regular SNMP now calls `Project(ConsumerMetrics, ConsumerLicensing)`, while topology and licensing-only callers continue using single-consumer projections.
720 +- Folded in the follow-up Claude implementation-slice review before starting the consumer rewrite:
721 + - accepted and fixed `mergeLicensing` override semantics by making derived `licensing:` rows replace inherited rows with the same pre-collection merge identity;
722 + - accepted and fixed the shared `missingOIDs` poisoning risk by removing the empty-walk table-missing cache write; explicit no-such GET responses still mark OIDs missing;
723 + - accepted and fixed licensing cross-table tag wiring by building a shared licensing table-name/OID map, walking cross-table dependencies before row processing, and caching dependency metadata;
724 + - accepted and fixed the D20 forbid-list gap by rejecting `extract_value`, `match_pattern`, and `match_value` on licensing row value symbols;
725 + - accepted and fixed state-policy-without-source validation, timer timestamp/remaining ambiguity, usage sentinel parity, cache-config namespace collisions, and duplicated structural-identity helper logic;
726 + - accepted and fixed licensing observability separation by adding `Stats.Timing.Licensing` and `Stats.Errors.Processing.Licensing`;
727 + - accepted the stats accounting direction: `Stats.Metrics.Licensing` counts typed license rows, while `Stats.Metrics.Tables` and `Stats.Metrics.Rows` remain ordinary chart-metric table counters;
728 + - rejected treating scalar `from:` acceptance as a blocker: scalar rows are the scalar group scope, table `from:` has the strict same-table gate, and cross-profile `from:` has no schema path.
729 +- Started the consumer rewrite slice:
730 + - replace `collector/snmp/licensing.go` hidden `_license_row*` / `_license_value_kind` extraction with typed `ProfileMetrics.LicenseRows`;
731 + - keep hidden-profile fixture tests only as migration reference, not as the target consumer contract;
732 + - update aggregation/state/function tests around typed rows, including ignored/informational/degraded/broken bucket behavior;
733 + - migrate vendor profile families only after the typed consumer is working.
734 +- Recorded Cisco licensing scope refinement:
735 + - Cisco licensing remains in dedicated licensing mixin/profile files, not `_cisco-base.yaml`;
736 + - current WIP Cisco licensing has two table OIDs (`clmgmtLicenseInfoTable` and `ciscoSlaEntitlementInfoTable`);
737 + - unsupported scalar GET OIDs are cached as exact missing OIDs after clean no-data responses;
738 + - unsupported table-root walks that return explicit `NoSuchObject` / `NoSuchInstance` should be distinguished from empty tables with zero rows;
739 + - user selected broad Cisco coverage: `cisco.yaml` should extend the dedicated Cisco traditional and smart licensing mixins;
740 + - broad Cisco licensing coverage is acceptable with a tracked follow-up for carefully scoped explicit table-root no-such caching so unsupported licensing table probes do not remain permanent per-poll cost.
741 +- Migrated Cisco licensing profiles:
742 + - added `_cisco-licensing-traditional.yaml` and `_cisco-licensing-smart.yaml`;
743 + - removed hidden Cisco `_license_row*` and `_license_value_kind` profile blocks from `_cisco-base.yaml`;
744 + - made `cisco.yaml` extend both dedicated licensing mixins;
745 + - Cisco Smart scalar OIDs now use scalar instance suffix `.0`; the Smart entitlement OID remains a table;
746 + - Cisco traditional typed rows use the three-component MIB index as the semantic row id, while runtime structural identity remains origin profile + table OID + row key;
747 + - treated zero-length SNMP `DateAndTime` values as no-value for licensing timers, matching the Cisco MIB's empty-octet-string not-applicable behavior.
748 +- Cisco migration validation passed:
749 + - `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestCollector_Collect_Cisco|TestCollector_Collect_.*LicensingProfile_Fixture'`;
750 + - `go test -count=1 ./collector/snmp/ddsnmp/...`;
751 + - `go test -count=1 ./collector/snmp/...`;
752 + - `rg '_license_row|_license_value_kind|licenseDateFromTag' config/go.d/snmp.profiles/default` returned no matches.
753 +- Folded in the final pre-PR review fix batch:
754 + - replaced all-at-once licensing chart registration with per-signal-class lazy registration and per-chart idempotent guards;
755 + - kept SNMP licensing health alerts scoped by their `snmp.license.*` contexts and added the agreed grace-period warning delay;
756 + - verified Fortinet licensing table OIDs against Fortinet's FortiGate system MIB documentation for `fgLicContractTable`, `fgLicVersionTable`, and `fgLicAlContractTable`, and recorded the official documentation URL in the Fortinet profile;
757 + - corrected Cisco Smart entitlement `invalidTag(11)` from healthy to broken based on the local `CISCO-SMART-LIC-MIB.my` definition;
758 + - removed the dead MikroTik filename/source-gated consumer sanity path because `timer_pre_1971` now drops the sentinel at typed-producer time;
759 + - documented licensing stats separation, documented and tested the intentionally parameterless `snmp:licenses` function contract, hardened scalar literal-only licensing row validation, expanded licensing forbid-list tests, and consolidated licensing fixture tests into a map-driven harness;
760 + - aligned `.agents/sow/specs/snmp-profile-projection.md` with implemented scalar `from:` validation and metadata `id_tags` projection behavior.
761 +- Updated durable project artifacts for the typed projection slice:
762 + - extended `.agents/sow/specs/snmp-profile-projection.md` with the licensing consumer, projection rules, typed delivery, identity rules, and validation guarantees;
763 + - extended `.agents/skills/project-snmp-profiles-authoring/SKILL.md` with licensing authoring guardrails and the `ProfileMetrics.LicenseRows` delivery rule.
764 +
765 +## Validation
766 +
767 +Acceptance criteria evidence:
768 +
769 +- Typed schema/projection:
770 + - `collector/snmp/ddsnmp/ddprofiledefinition/licensing.go` defines typed licensing config, closed signal kinds, sentinel policies, state policies, clone methods, and source fields.
771 + - `collector/snmp/ddsnmp/metric.go` defines `ProfileMetrics.LicenseRows` and typed `LicenseRow`, `LicenseState`, `LicenseTimer`, and `LicenseUsage`.
772 + - `collector/snmp/profile_sets.go` uses `Project(ConsumerMetrics, ConsumerLicensing)` so regular SNMP collection gets metrics and licensing in one ddsnmpcollector pass.
773 +- Hidden protocol removal:
774 + - `rg '_license_row|_license_value_kind|licenseDateFromTag' config/go.d/snmp.profiles/default` returned no matches during implementation.
775 + - Runtime licensing consumes `pm.LicenseRows`; `HiddenMetrics` remains only as generic underscore/private metric delivery.
776 +- Profile correctness:
777 + - Cisco licensing lives in `_cisco-licensing-traditional.yaml` and `_cisco-licensing-smart.yaml`, with `cisco.yaml` broadly extending both by user decision.
778 + - Cisco Smart scalar OIDs use scalar instance `.0`; the entitlement table remains modeled as a table.
779 + - Cisco traditional identity derives the three-component MIB index instead of feature-name-only identity.
780 + - Blue Coat derives `appLicenseStatusIndex` from the row index.
781 + - Check Point licensing follows the refreshed `svnLicensing` table.
782 + - MikroTik pre-1971 sentinel filtering happens at typed producer time, not by filename.
783 +- Final review fixes:
784 + - Licensing errors are best-effort relative to regular scalar/table metrics.
785 + - Scalar licensing OIDs honor the shared exact missing-OID cache before future GETs.
786 + - Workstation-local fixture provenance paths were removed.
787 + - Public metadata wording no longer uses PR-specific branch coverage phrasing.
788 + - Global integration template edits were reverted to avoid a repo-wide generated-doc inconsistency.
789 +
790 +Tests or equivalent validation:
791 +
792 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestParseSNMPWalkLine_IntegerEnumValue|TestCollector_Collect_CheckPointLicensingProfile_CommunitySample|TestCollector_Collect_SophosLicensingProfile_Fixture$'` passed.
793 +- `go test -count=1 ./collector/snmp -run 'TestFuncLicensesHandleUnavailable|TestFuncLicensesHandleUnavailableWhenNoRowsWereCollected|TestFuncLicensesHandle$'` passed, covering the existing cold-start `503` behavior.
794 +- `go test -count=1 ./collector/snmp/ddsnmp/ddprofiledefinition` passed.
795 +- `go test -count=1 ./collector/snmp/ddsnmp` passed.
796 +- `go test -count=1 ./collector/snmp/ddsnmp -run 'TestProfile_MergeLicensingPreservesOriginProfileID|TestProfileDefinition|TestResolvedProfileSet_Project'` passed.
797 +- `go test -count=1 ./collector/snmp/ddsnmp -run 'TestResolvedProfileSetProject_SeparatesMetricsAndTopology|TestResolvedProfileSetProject_DoesNotShareMutableProjectionState|TestProjectedViewFilterByKind'` passed.
798 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestCollector_Collect_LicenseRows'` passed, covering scalar and table typed licensing row emission plus table-cache reuse.
799 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestCollector_Collect_PreservesHiddenMetrics|TestCollector_Collect_SeparatesTopologyMetricsFromHiddenMetrics|TestCollector_Collect_LicenseRows'` passed, covering hidden/topology/licensing delivery separation.
800 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestCollector_Collect_LicenseRows|TestCollector_Collect_StatsSnapshot'` passed, covering typed licensing rows and the normal non-licensing stats path.
801 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run TestParseSNMPWalkLine_IntegerEnumValue` passed, compiling `ddsnmpcollector` after shared `ProfileMetrics` changes.
802 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestCollector_Collect_(Cisco|MikroTik)LicensingProfile'` passed after clearing inherited topology rows from the hidden-protocol licensing profile helper.
803 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestCollector_Collect_LicenseRows|TestCollector_Collect_PreservesHiddenMetrics|TestCollector_Collect_SeparatesTopologyMetricsFromHiddenMetrics|TestCollector_Collect_StatsSnapshot'` passed after the combined projection, usage-sentinel, and licensing-stats fixes.
804 +- `go test -count=1 ./collector/snmp/ddsnmp` passed after replacing the one-off combined projection helper with variadic `Project(consumer, consumers...)`.
805 +- `go test -count=1 ./collector/snmp/ddsnmp/... ./collector/snmp/... ./collector/snmp_topology/...` passed after replacing the one-off combined projection helper with variadic `Project(consumer, consumers...)`.
806 +- `go test -count=1 ./collector/snmp/ddsnmp/ddprofiledefinition` passed after adding the licensing forbid-list, state-source, and timer-source validation gates.
807 +- `go test -count=1 ./collector/snmp/ddsnmp` passed after the variadic projection and licensing merge-override fixes.
808 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector` passed after the cross-table licensing dependency walk, missing-OID, cache-namespace, sentinel-matrix, and licensing stat fixes.
809 +- `go test -count=1 ./collector/snmp/ddsnmp/... ./collector/snmp/... ./collector/snmp_topology/...` passed after the full post-review fix batch.
810 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector` passed.
811 +- `go test -count=1 ./collector/snmp/ddsnmp/...` passed.
812 +- `go test -count=1 ./collector/snmp/...` passed.
813 +- `go test -count=1 ./collector/snmp_topology/...` passed, covering shared profile projection changes against the topology consumer.
814 +- `go test -count=1 ./collector/snmp` passed after replacing the licensing consumer's hidden-metric extraction with typed `ProfileMetrics.LicenseRows`, adding the `informational` bucket, and rewriting SNMP collector aggregation/function tests around typed rows.
815 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector` passed after migrating MikroTik, Check Point, Blue Coat, and Fortinet licensing profiles from hidden `_license_row` metrics to typed `licensing:` rows.
816 +- `go test -count=1 ./collector/snmp/ddsnmp/... ./collector/snmp` passed after the typed consumer rewrite, typed profile migrations, and licensing documentation updates in this slice.
817 +- `go test -count=1 ./collector/snmp/... ./collector/snmp_topology/...` passed after the typed consumer rewrite, migrated profile subset, and documentation/health updates.
818 +- `go test -count=1 ./collector/snmp -run 'TestCollector_AddLicenseCharts|TestLicensesMethodConfig|TestFuncLicenses|TestAggregateLicenseRows|TestNormalizeLicenseStateBucket|TestExtractLicenseRows'` passed after lazy chart registration, parameterless function contract, and typed consumer cleanup.
819 +- `go test -count=1 ./collector/snmp/ddsnmp/ddprofiledefinition ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestValidateEnrichProfile_Licensing|TestCollector_Collect_LicensingProfileFixtures|TestCollector_Collect_LicensingProfiles|TestCollector_Collect_Sophos|TestCollector_Collect_MikroTik|TestCollector_Collect_Cisco'` passed after licensing validation hardening and fixture test consolidation.
820 +- `go test -count=1 ./collector/snmp/ddsnmp/...` passed after the final pre-PR review fix batch.
821 +- `go test -count=1 ./collector/snmp/...` passed after the final pre-PR review fix batch.
822 +- `go test -count=1 ./collector/snmp_topology/...` passed after the shared projection/spec cleanup.
823 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector -run 'TestCollector_Collect_LicenseRowsBestEffortForRegularMetrics|TestCollector_Collect_LicenseRowsSkipsKnownMissingScalarOIDs'` passed after the final review runtime fixes.
824 +- `go test -count=1 ./collector/snmp/ddsnmp/...` passed after the final review runtime and artifact fixes.
825 +- `go test -count=1 ./collector/snmp/... ./collector/snmp_topology/...` passed after the final review runtime and artifact fixes.
826 +- `git diff --check master...HEAD` passed during final review.
827 +
828 +Real-use evidence:
829 +
830 +- No live SNMP device validation was run in this SOW. Licensing profile behavior is validated through typed collector mocks, public fixture-derived SNMP data, full-profile loading tests, and MIB-derived OID checks. This is acceptable for the WIP/nightly feature because the target devices are not locally available and all changed runtime surfaces are covered by narrow Go suites.
831 +
832 +Reviewer findings:
833 +
834 +- Latest Claude implementation-slice review disposition:
835 + - accepted/fixed P0 merge override, missing-OID poisoning, and licensing cross-table dependency wiring before consumer rewrite;
836 + - accepted/fixed P1 validation/runtime gaps for forbidden licensing symbol transforms, state policy source, timer source ambiguity, cache namespace, licensing timing/error counters, and structural helper reuse;
837 + - accepted/documented separate stats semantics for typed license rows versus ordinary chart-metric table rows;
838 + - rejected scalar `from:` as an additional blocker because scalar `from:` is scoped to the scalar licensing row/group by construction, while table `from:` remains explicitly same-table validated.
839 +- Final GPT-5.5 review disposition:
840 + - accepted/fixed licensing errors dropping regular metrics by making typed licensing best-effort in `collectProfile`;
841 + - accepted/fixed scalar licensing missing-OID cache bypass by filtering known missing scalar licensing OIDs before future GETs;
842 + - accepted/fixed fixture workstation-path leakage by sanitizing licensing fixture headers;
843 + - accepted/fixed stale public docs wording in `metadata.yaml`;
844 + - accepted/fixed projection spec example severity mappings to use runtime-valid `"0"`, `"1"`, `"2"` values;
845 + - accepted/fixed generated-doc/template inconsistency by reverting unrelated global integration template edits;
846 + - accepted/tracked Cisco unsupported table-root no-such caching as `.agents/sow/pending/SOW-0014-20260507-snmp-licensing-unsupported-table-cache.md`.
847 +
848 +Same-failure scan:
849 +
850 +- Same-failure search for workstation-local provenance paths and PR-specific branch coverage phrasing returned no matches in metadata, licensing fixtures, projection specs, and integration templates.
851 +- `rg -n "_license_row|_license_value_kind|licenseDateFromTag|tagLicense|licenseValueKind|licenseSourceMetricName|mergeLicenseSignal|mergeLicenseTags|licenseRowMergeKey" collector/snmp config/go.d/snmp.profiles/default` shows only intentional validation-test literals and generic `HiddenMetrics` canary references, not production licensing consumption.
852 +- `git status --short` was checked; raw MIB files were removed from repo root and the local TODO remained untracked.
853 +
854 +Sensitive data gate:
855 +
856 +- Durable artifacts contain no raw SNMP communities, SNMPv3 credentials, bearer tokens, customer names, customer hostnames, customer IPs, or raw MIB content.
857 +- Workstation-local fixture provenance comments were sanitized.
858 +- The four downloaded raw MIB files were deleted from repo root before close-out.
859 +- `src/go/plugin/go.d/TODO-snmp-licensing-monitoring-review.md` remains local/untracked and must not be staged.
860 +
861 +Artifact maintenance gate:
862 +
863 +- AGENTS.md: no update needed; existing SOW, collector, sensitive-data, and follow-up discipline rules already covered this work.
864 +- Runtime project skills: updated `.agents/skills/project-snmp-profiles-authoring/SKILL.md` with typed licensing authoring rules and table-driven test preference.
865 +- Specs: updated `.agents/sow/specs/snmp-profile-projection.md` with licensing consumer, projection, typed delivery, identity, validation, and metadata tag behavior.
866 +- End-user/operator docs: updated `collector/snmp/profile-format.md`, `collector/snmp/metadata.yaml`, `collector/snmp/integrations/snmp_devices.md`, and `src/health/health.d/snmp.conf` as the SNMP licensing feature became user-visible.
867 +- End-user/operator skills: no update needed; no public Netdata AI skill currently documents SNMP profile licensing authoring or SNMP licensing runtime use.
868 +- SOW lifecycle: SOW-0013 status set to `completed`; file will move to `.agents/sow/done/` with the implementation and follow-up SOW in the same commit.
869 +
870 +Specs update:
871 +
872 +- Updated `.agents/sow/specs/snmp-profile-projection.md`.
873 +
874 +Project skills update:
875 +
876 +- Updated `.agents/skills/project-snmp-profiles-authoring/SKILL.md`.
877 +
878 +End-user/operator docs update:
879 +
880 +- Updated:
881 + - `collector/snmp/profile-format.md`
882 + - `collector/snmp/metadata.yaml`
883 + - `collector/snmp/integrations/snmp_devices.md`
884 + - `src/health/health.d/snmp.conf`
885 +
886 +End-user/operator skills update:
887 +
888 +- No update needed; no end-user/operator skill exposes this SNMP licensing schema or function workflow.
889 +
890 +Lessons:
891 +
892 +- Hidden-metric side channels become hard to validate as soon as one logical row is reconstructed from several scalar/table fragments. Topology and licensing now share the cleaner pattern: schema-owned typed sections plus typed `ProfileMetrics` outputs.
893 +- Optional feature telemetry must be best-effort relative to ordinary device metrics; otherwise a WIP optional section can regress established collection.
894 +
895 +Follow-up mapping:
896 +
897 +- Implemented in this SOW:
898 + - typed licensing projection and consumer rewrite;
899 + - profile migrations and MIB-derived corrections;
900 + - hidden-protocol removal from production licensing;
901 + - validation/test/docs/spec/skill updates;
902 + - final review runtime and artifact fixes.
903 +- Tracked as follow-up:
904 + - `.agents/sow/pending/SOW-0014-20260507-snmp-licensing-unsupported-table-cache.md` tracks explicit unsupported licensing table-root no-such caching for broad Cisco coverage.
905 +
906 +## Outcome
907 +
908 +Completed. SNMP licensing is now represented by top-level typed `licensing:` profile rows and delivered through `ProfileMetrics.LicenseRows`; the SNMP collector consumes typed rows directly for charts, health inputs, and the `snmp:licenses` function. The old hidden `_license_row*` / `_license_value_kind` protocol is removed from production licensing code and profile YAML.
909 +
910 +## Lessons Extracted
911 +
912 +Typed profile projections are the right boundary for non-chart SNMP observations. The collector can still reuse scalar/table collection internals, but row identity, validation, and consumer contracts need to live in schema-owned typed fields, not hidden metric names or string tag protocols.
913 +
914 +## Followup
915 +
916 +- `.agents/sow/pending/SOW-0014-20260507-snmp-licensing-unsupported-table-cache.md`
917 +
918 +## Regression Log
919 +
920 +None yet.
.agents/sow/pending/SOW-0014-20260507-snmp-licensing-unsupported-table-cache.md new
+220
@@ -0,0 +1,220 @@
1 +# SOW-0014 - SNMP licensing unsupported table cache
2 +
3 +## Status
4 +
5 +Status: open
6 +
7 +Sub-state: follow-up created from SOW-0013 close-out review.
8 +
9 +## Requirements
10 +
11 +### Purpose
12 +
13 +Avoid repeated SNMP licensing table-root walks on Cisco and other devices that explicitly report unsupported licensing tables, without reintroducing broad missing-OID poisoning for ordinary metric tables or empty-but-valid tables.
14 +
15 +### User Request
16 +
17 +User accepted broad Cisco licensing coverage through dedicated mixins and agreed that nonexistent licensing table OID caching should be tracked as the solution for unsupported devices.
18 +
19 +### Assistant Understanding
20 +
21 +Facts:
22 +
23 +- `cisco.yaml` extends `_cisco-licensing-traditional.yaml` and `_cisco-licensing-smart.yaml`.
24 +- Those mixins add two licensing table roots: `clmgmtLicenseInfoTable` and `ciscoSlaEntitlementInfoTable`.
25 +- SOW-0013 intentionally removed the old empty-walk table missing cache write because empty tables and unsupported tables must not be conflated.
26 +- Explicit `NoSuchObject` / `NoSuchInstance` table-root responses are still a reasonable candidate for a narrowly scoped licensing unsupported-table cache.
27 +
28 +Inferences:
29 +
30 +- The follow-up should distinguish unsupported table roots from empty tables with zero rows.
31 +- The cache should be scoped to typed licensing table roots and must not suppress ordinary metric table OIDs.
32 +
33 +Unknowns:
34 +
35 +- Exact `gosnmp` walk response shapes for explicit table-root `NoSuchObject` / `NoSuchInstance` need fixture or mock confirmation before implementation.
36 +
37 +### Acceptance Criteria
38 +
39 +- Typed licensing table walks that receive explicit no-such table-root responses are skipped on later collection cycles.
40 +- Empty valid tables with zero rows are not cached as unsupported.
41 +- Ordinary metrics and topology table collection are unaffected.
42 +- Tests cover unsupported table root, empty table, and ordinary metric table behavior.
43 +
44 +## Analysis
45 +
46 +Sources checked:
47 +
48 +- `src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_licensing.go`
49 +- `src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table.go`
50 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/cisco.yaml`
51 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/_cisco-licensing-traditional.yaml`
52 +- `src/go/plugin/go.d/config/go.d/snmp.profiles/default/_cisco-licensing-smart.yaml`
53 +- `.agents/sow/current/SOW-0013-20260507-snmp-licensing-projection.md`
54 +
55 +Current state:
56 +
57 +- Missing scalar OIDs are cached exactly.
58 +- Licensing table roots with no returned PDUs are not cached, by design, because zero rows may mean an empty valid table.
59 +- Broad Cisco licensing can therefore continue walking unsupported licensing table roots each cycle until this follow-up is implemented.
60 +
61 +Risks:
62 +
63 +- Over-broad caching would hide licensing data that appears later or suppress unrelated regular metrics.
64 +- Under-scoped caching leaves avoidable per-cycle Cisco probe cost.
65 +
66 +## Pre-Implementation Gate
67 +
68 +Status: blocked
69 +
70 +Problem / root-cause model:
71 +
72 +- Broad Cisco licensing coverage intentionally trades initial probe breadth for profile simplicity. Unsupported table roots need a precise negative-cache path keyed to explicit no-such table-root responses, not to empty walk results.
73 +
74 +Evidence reviewed:
75 +
76 +- `collector_licensing.go` walks licensing table roots on cache misses.
77 +- `collector_table.go` no longer marks zero-PDU walks missing.
78 +- SOW-0013 execution log records this as a follow-up requirement.
79 +
80 +Affected contracts and surfaces:
81 +
82 +- ddsnmpcollector licensing table collection.
83 +- Collection stats for missing OIDs and table walks.
84 +- Cisco SNMP collection overhead on unsupported devices.
85 +
86 +Existing patterns to reuse:
87 +
88 +- Shared exact `missingOIDs` scalar cache.
89 +- Existing table cache and SNMP walk helpers.
90 +- Existing mock SNMP tests under `ddsnmpcollector`.
91 +
92 +Risk and blast radius:
93 +
94 +- Narrow runtime collector behavior change.
95 +- Must not alter profile schema.
96 +- Must not change ordinary table metric missing behavior.
97 +
98 +Sensitive data handling plan:
99 +
100 +- Use synthetic SNMP mock PDUs only. Do not add real device walks or raw MIB content.
101 +
102 +Implementation plan:
103 +
104 +1. Model explicit table-root no-such responses in tests.
105 +2. Add a licensing-scoped unsupported table-root cache or equivalent filter.
106 +3. Prove empty valid tables are still retried.
107 +
108 +Validation plan:
109 +
110 +- `go test -count=1 ./collector/snmp/ddsnmp/ddsnmpcollector`
111 +- `go test -count=1 ./collector/snmp/ddsnmp/...`
112 +- `go test -count=1 ./collector/snmp/...`
113 +
114 +Artifact impact plan:
115 +
116 +- AGENTS.md: no expected update.
117 +- Runtime project skills: no expected update unless a new authoring rule emerges.
118 +- Specs: update `.agents/sow/specs/snmp-profile-projection.md` if the cache becomes a durable projection behavior.
119 +- End-user/operator docs: no expected update.
120 +- End-user/operator skills: no expected update.
121 +- SOW lifecycle: track this as the SOW-0013 follow-up.
122 +
123 +Open-source reference evidence:
124 +
125 +- None. This is collector runtime behavior with synthetic tests.
126 +
127 +Open decisions:
128 +
129 +- None for planning. Implementation details should be validated against actual `gosnmp` no-such walk behavior before patching.
130 +
131 +## Implications And Decisions
132 +
133 +- Follow-up created to satisfy SOW-0013 follow-up discipline; no implementation is part of SOW-0013.
134 +
135 +## Plan
136 +
137 +1. Add tests for unsupported licensing table-root no-such handling.
138 +2. Implement narrowly scoped cache/filter behavior.
139 +3. Validate narrow and shared SNMP suites.
140 +
141 +## Execution Log
142 +
143 +### 2026-05-07
144 +
145 +- Created as follow-up from SOW-0013 final review.
146 +
147 +## Validation
148 +
149 +Acceptance criteria evidence:
150 +
151 +- Pending implementation.
152 +
153 +Tests or equivalent validation:
154 +
155 +- Pending implementation.
156 +
157 +Real-use evidence:
158 +
159 +- Pending implementation.
160 +
161 +Reviewer findings:
162 +
163 +- Pending implementation.
164 +
165 +Same-failure scan:
166 +
167 +- Pending implementation.
168 +
169 +Sensitive data gate:
170 +
171 +- Pending implementation.
172 +
173 +Artifact maintenance gate:
174 +
175 +- AGENTS.md: pending implementation outcome.
176 +- Runtime project skills: pending implementation outcome.
177 +- Specs: pending implementation outcome.
178 +- End-user/operator docs: pending implementation outcome.
179 +- End-user/operator skills: pending implementation outcome.
180 +- SOW lifecycle: pending implementation outcome.
181 +
182 +Specs update:
183 +
184 +- Pending implementation.
185 +
186 +Project skills update:
187 +
188 +- Pending implementation.
189 +
190 +End-user/operator docs update:
191 +
192 +- Pending implementation.
193 +
194 +End-user/operator skills update:
195 +
196 +- Pending implementation.
197 +
198 +Lessons:
199 +
200 +- Pending implementation.
201 +
202 +Follow-up mapping:
203 +
204 +- Pending implementation.
205 +
206 +## Outcome
207 +
208 +Pending.
209 +
210 +## Lessons Extracted
211 +
212 +Pending.
213 +
214 +## Followup
215 +
216 +None yet.
217 +
218 +## Regression Log
219 +
220 +None yet.
.agents/sow/specs/snmp-profile-projection.md
+133 -9
@@ -3,9 +3,9 @@
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.
6 +Regular SNMP metric collection, SNMP topology, and SNMP licensing use the same
7 +profile loading, matching, inheritance, metadata, and tag machinery, but they
8 +consume different profile views.
9
10 ## Consumers
11
@@ -13,21 +13,30 @@ The supported profile consumers are:
13
14 - `metrics` - regular SNMP charted metrics and virtual metrics.
15 - `topology` - SNMP topology observations.
16 +- `licensing` - typed SNMP network-device license rows.
17
17 -Profile metadata fields and top-level `metric_tags` default to both consumers.
18 +Profile metadata fields and top-level `metric_tags` default to all consumers.
19 They may narrow their visibility with:
20
21 ```yaml
22 consumers: [metrics]
23 consumers: [topology]
24 +consumers: [licensing]
25 ```
26
27 +Metadata resource `id_tags` do not carry `consumers` today. They inherit the
28 +metadata defaults used by charted metrics and topology and are not included in a
29 +licensing-only projection.
30 +
31 Metric rows under top-level `metrics:` are regular metric rows. They are
32 metrics-only.
33
34 Topology rows live under top-level `topology:` and must declare a closed
35 `kind`.
36
37 +Licensing rows live under top-level `licensing:` and emit typed license rows,
38 +not chart metrics and not hidden underscore-prefixed metrics.
39 +
40 ## Topology Rows
41
42 Topology rows reuse the regular `MetricsConfig` scalar/table shape:
@@ -80,6 +89,60 @@ The closed topology kind set is:
89 - `arp_entry`
90 - `arp_legacy_entry`
91
92 +## Licensing Rows
93 +
94 +Licensing rows are row-centric because one license row can aggregate identity,
95 +descriptors, state, timers, and usage signals:
96 +
97 +```yaml
98 +licensing:
99 + - id: vendor-license-group
100 + table:
101 + OID: 1.3.6.1.4.1.example.1
102 + name: vendorLicenseTable
103 + identity:
104 + id: { index: 1 }
105 + name:
106 + symbol:
107 + OID: 1.3.6.1.4.1.example.1.2
108 + name: vendorLicenseName
109 + state:
110 + symbol:
111 + OID: 1.3.6.1.4.1.example.1.3
112 + name: vendorLicenseState
113 + mapping:
114 + 1: "0"
115 + 2: "1"
116 + 3: "2"
117 + signals:
118 + expiry:
119 + timestamp:
120 + symbol:
121 + OID: 1.3.6.1.4.1.example.1.4
122 + name: vendorLicenseExpiry
123 + format: snmp_dateandtime
124 +```
125 +
126 +Scalar-only licensing rows are allowed. If a scalar row combines multiple
127 +scalar signal OIDs into one license row, it must declare an explicit `id:` so
128 +the grouping is stable. Otherwise scalar structural identity defaults to the
129 +single scalar signal OID.
130 +
131 +`from: <oid>` lets a typed licensing value read a sibling OID directly. For
132 +table rows, `from` must be a peer column under the same table OID. For scalar
133 +rows, schema validation only checks OID syntax and the row's explicit identity
134 +rules; there is no cross-profile reference path in profile validation.
135 +
136 +Supported licensing signal fields are:
137 +
138 +- state severity: `state`
139 +- timers: `expiry`, `authorization`, `certificate`, `grace`
140 +- usage: `used`, `capacity`, `available`, `percent`
141 +
142 +Timer signals may declare exactly one of the shorthand timer value,
143 +`timestamp`, or `remaining`. Sentinel policies are closed names and are
144 +evaluated by the typed licensing producer before runtime consumers see the row.
145 +
146 ## Resolve And Projection
147
148 `ddsnmp.Catalog.Resolve()` resolves profiles by `sysObjectID`, `sysDescr`, and
@@ -100,14 +163,26 @@ semantics. The topology collector uses manual-profile augment semantics.
163 - drops `virtual_metrics`;
164 - filters metadata and top-level metric tags by `consumers`.
165
166 +`ResolvedProfileSet.Project(licensing)` returns the licensing view:
167 +
168 +- keeps `licensing`;
169 +- drops regular `metrics`;
170 +- drops `topology`;
171 +- drops `virtual_metrics`;
172 +- filters metadata and top-level metric tags by `consumers`.
173 +
174 +The regular SNMP collector uses `Project(metrics, licensing)` so one SNMP pass
175 +can produce charted metrics and typed license rows. Single-consumer projections
176 +remain pure.
177 +
178 `ProjectedView.FilterByKind()` is a topology view filter. VLAN-context topology
179 uses it with the VLAN-scopable kind set instead of hardcoded topology mixin
180 filenames.
181
182 ## Inheritance And Merge Rules
183
109 -Profile inheritance must merge `topology:` rows in addition to `metrics:`,
110 -`virtual_metrics`, metadata, global metric tags, and static tags.
184 +Profile inheritance must merge `topology:` and `licensing:` rows in addition to
185 +`metrics:`, `virtual_metrics`, metadata, global metric tags, and static tags.
186
187 Topology row identity is:
188
@@ -124,7 +199,33 @@ identity are load errors.
199
200 Cross-profile deduplication runs after profile matching because it depends on
201 matched-profile specificity. It must deduplicate both regular metrics and
127 -topology rows in the resolved matched set.
202 +topology/licensing rows in the resolved matched set.
203 +
204 +Runtime licensing row structural identity is:
205 +
206 +```text
207 +origin profile id + table OID + row index
208 +origin profile id + scalar signal OID
209 +origin profile id + explicit scalar group id
210 +```
211 +
212 +`origin profile id` is the logical profile file that declared the licensing row,
213 +including mixin-origin rows after `extends:` merge. It is not the root matched
214 +profile name and not an absolute workstation path. Repeated `(structural
215 +identity, signal kind)` entries are load errors unless a valid inheritance
216 +override handles them.
217 +
218 +Profile inheritance merge identity is the pre-collection form of that identity:
219 +
220 +```text
221 +table OID
222 +scalar signal OID
223 +explicit scalar group id
224 +```
225 +
226 +Derived `licensing:` rows with the same merge identity replace inherited rows.
227 +This keeps intentional `extends:` overrides valid while duplicate signal kinds
228 +inside one resolved profile remain load errors.
229
230 ## Delivery
231
@@ -133,9 +234,18 @@ Regular metrics are emitted through `ProfileMetrics.Metrics`.
234 Topology rows are emitted through `ProfileMetrics.TopologyMetrics` and carry
235 `Metric.TopologyKind`.
236
237 +Licensing rows are emitted through `ProfileMetrics.LicenseRows` and carry
238 +typed grouped fields for identity, descriptors, state, timers, usage, tags,
239 +origin profile id, table OID, row key, and structural id.
240 +
241 `ProfileMetrics.HiddenMetrics` remains a generic delivery container for
137 -underscore-prefixed non-topology metrics. SNMP topology must not depend on
138 -hidden metrics.
242 +underscore-prefixed non-topology and non-licensing metrics. SNMP topology and
243 +SNMP licensing must not depend on hidden metrics.
244 +
245 +Licensing row counts are reported through `Stats.Metrics.Licensing`. Ordinary
246 +`Stats.Metrics.Tables` and `Stats.Metrics.Rows` remain regular chart-metric
247 +table counters. Licensing timing and processing failures use their own
248 +licensing fields in timing and processing-error stats.
249
250 Top-level `metric_tags` on topology projections are profile/device labels. They
251 are applied through topology profile-tag ingestion and are not topology row
@@ -148,5 +258,19 @@ Profile validation rejects:
258 - unknown topology kinds;
259 - underscore-prefixed topology row value symbol names;
260 - regular metric chart/export-only fields on topology row value symbols;
261 +- unknown licensing signal, sentinel, and state policy names;
262 +- licensing rows without state or signals;
263 +- scalar licensing rows that group multiple scalar signal OIDs without an
264 + explicit `id`;
265 +- scalar licensing rows with only literal values and no explicit `id`;
266 +- repeated licensing signal kinds for the same structural identity;
267 +- licensing table `from` OIDs outside the row table;
268 +- underscore-prefixed licensing value names;
269 +- regular metric chart/export-only fields, transforms, scale factors, and
270 + constant-value hacks on licensing row value symbols;
271 +- `extract_value`, `match_pattern`, or `match_value` on licensing row value
272 + symbols;
273 +- timer slots that set both timestamp-style and remaining-style values;
274 +- unsupported licensing value formats;
275 - invalid `consumers` values;
276 - virtual metrics whose sources resolve to topology rows.
src/go/plugin/go.d/collector/snmp/charts.go
+6 -1
@@ -16,8 +16,10 @@ const (
16 prioProfileChart = collectorapi.Priority + iota
17 prioPingRtt
18 prioPingStdDev
19 +)
20
20 - prioInternalStatsTimings
21 +const (
22 + prioInternalStatsTimings = prioLicenseState + 1 + iota
23 prioInternalStatsSnmpOps
24 prioInternalStatsMetrics
25 prioInternalStatsTableCache
@@ -91,6 +93,7 @@ var (
93 Dims: collectorapi.Dims{
94 {ID: "snmp_device_prof_%s_stats_timings_scalar", Name: "scalar"},
95 {ID: "snmp_device_prof_%s_stats_timings_table", Name: "table"},
96 + {ID: "snmp_device_prof_%s_stats_timings_licensing", Name: "licensing"},
97 {ID: "snmp_device_prof_%s_stats_timings_virtual", Name: "virtual"},
98 },
99 }
@@ -123,6 +126,7 @@ var (
126 {ID: "snmp_device_prof_%s_stats_metrics_scalar", Name: "scalar"},
127 {ID: "snmp_device_prof_%s_stats_metrics_table", Name: "table"},
128 {ID: "snmp_device_prof_%s_stats_metrics_virtual", Name: "virtual"},
129 + {ID: "snmp_device_prof_%s_stats_metrics_licensing", Name: "licensing"},
130 {ID: "snmp_device_prof_%s_stats_metrics_tables", Name: "tables"},
131 {ID: "snmp_device_prof_%s_stats_metrics_rows", Name: "rows"},
132 },
@@ -152,6 +156,7 @@ var (
156 {ID: "snmp_device_prof_%s_stats_errors_snmp", Name: "snmp"},
157 {ID: "snmp_device_prof_%s_stats_errors_processing_scalar", Name: "processing_scalar"},
158 {ID: "snmp_device_prof_%s_stats_errors_processing_table", Name: "processing_table"},
159 + {ID: "snmp_device_prof_%s_stats_errors_processing_licensing", Name: "processing_licensing"},
160 },
161 }
162 )
src/go/plugin/go.d/collector/snmp/charts_test.go
+72
@@ -77,6 +77,78 @@ func TestAddMetricTagLabels_PrefersUnprefixedTags(t *testing.T) {
77 }, labels)
78 }
79
80 +func TestLicenseChartsSkipGaps(t *testing.T) {
81 + tests := map[string]struct {
82 + skip bool
83 + }{
84 + licenseRemainingTimeChart.ID: {skip: licenseRemainingTimeChart.SkipGaps},
85 + licenseAuthorizationRemainingTimeChart.ID: {skip: licenseAuthorizationRemainingTimeChart.SkipGaps},
86 + licenseCertificateRemainingTimeChart.ID: {skip: licenseCertificateRemainingTimeChart.SkipGaps},
87 + licenseGraceRemainingTimeChart.ID: {skip: licenseGraceRemainingTimeChart.SkipGaps},
88 + licenseUsagePercentChart.ID: {skip: licenseUsagePercentChart.SkipGaps},
89 + licenseStateChart.ID: {skip: licenseStateChart.SkipGaps},
90 + }
91 +
92 + for name, tc := range tests {
93 + t.Run(name, func(t *testing.T) {
94 + assert.True(t, tc.skip, "chart %s must skip gaps to avoid empty licensing charts", name)
95 + })
96 + }
97 +}
98 +
99 +func TestCollector_AddLicenseCharts_LazyBySignalClass(t *testing.T) {
100 + tests := map[string]struct {
101 + agg licenseAggregate
102 + present []string
103 + absent []string
104 + }{
105 + "state only": {
106 + agg: licenseAggregate{hasStateCounts: true},
107 + present: []string{
108 + licenseStateChart.ID,
109 + },
110 + absent: []string{
111 + licenseRemainingTimeChart.ID,
112 + licenseAuthorizationRemainingTimeChart.ID,
113 + licenseCertificateRemainingTimeChart.ID,
114 + licenseGraceRemainingTimeChart.ID,
115 + licenseUsagePercentChart.ID,
116 + },
117 + },
118 + "expiry and usage only": {
119 + agg: licenseAggregate{hasRemainingTime: true, hasUsagePercent: true},
120 + present: []string{
121 + licenseRemainingTimeChart.ID,
122 + licenseUsagePercentChart.ID,
123 + },
124 + absent: []string{
125 + licenseAuthorizationRemainingTimeChart.ID,
126 + licenseCertificateRemainingTimeChart.ID,
127 + licenseGraceRemainingTimeChart.ID,
128 + licenseStateChart.ID,
129 + },
130 + },
131 + }
132 +
133 + for name, tc := range tests {
134 + t.Run(name, func(t *testing.T) {
135 + collr := New()
136 + collr.sysInfo = &snmputils.SysInfo{}
137 + collr.addLicenseCharts(tc.agg)
138 + collr.addLicenseCharts(tc.agg)
139 +
140 + for _, id := range tc.present {
141 + chart := collr.Charts().Get(id)
142 + require.NotNil(t, chart, "expected chart %s", id)
143 + assert.Equal(t, "licensing", chartLabels(chart)["component"])
144 + }
145 + for _, id := range tc.absent {
146 + assert.Nil(t, collr.Charts().Get(id), "unexpected chart %s", id)
147 + }
148 + })
149 + }
150 +}
151 +
152 func chartLabels(chart *collectorapi.Chart) map[string]string {
153 labels := make(map[string]string, len(chart.Labels))
154 for _, label := range chart.Labels {
src/go/plugin/go.d/collector/snmp/collect_snmp.go
+4
@@ -22,6 +22,7 @@ func (c *Collector) collectSNMP(mx map[string]int64) error {
22 }
23
24 c.resetIfaceCache()
25 + c.collectLicensing(mx, pms)
26
27 c.collectProfileScalarMetrics(mx, pms)
28 c.collectProfileTableMetrics(mx, pms)
@@ -114,6 +115,7 @@ func (c *Collector) collectProfileStats(mx map[string]int64, pms []*ddsnmp.Profi
115 px := fmt.Sprintf("snmp_device_prof_%s_stats_", name)
116 mx[px+"timings_scalar"] = pm.Stats.Timing.Scalar.Milliseconds()
117 mx[px+"timings_table"] = pm.Stats.Timing.Table.Milliseconds()
118 + mx[px+"timings_licensing"] = pm.Stats.Timing.Licensing.Milliseconds()
119 mx[px+"timings_virtual"] = pm.Stats.Timing.VirtualMetrics.Milliseconds()
120 mx[px+"snmp_get_requests"] = pm.Stats.SNMP.GetRequests
121 mx[px+"snmp_get_oids"] = pm.Stats.SNMP.GetOIDs
@@ -124,6 +126,7 @@ func (c *Collector) collectProfileStats(mx map[string]int64, pms []*ddsnmp.Profi
126 mx[px+"metrics_scalar"] = pm.Stats.Metrics.Scalar
127 mx[px+"metrics_table"] = pm.Stats.Metrics.Table
128 mx[px+"metrics_virtual"] = pm.Stats.Metrics.Virtual
129 + mx[px+"metrics_licensing"] = pm.Stats.Metrics.Licensing
130 mx[px+"metrics_tables"] = pm.Stats.Metrics.Tables
131 mx[px+"metrics_rows"] = pm.Stats.Metrics.Rows
132 mx[px+"table_cache_hits"] = pm.Stats.TableCache.Hits
@@ -131,6 +134,7 @@ func (c *Collector) collectProfileStats(mx map[string]int64, pms []*ddsnmp.Profi
134 mx[px+"errors_snmp"] = pm.Stats.Errors.SNMP
135 mx[px+"errors_processing_scalar"] = pm.Stats.Errors.Processing.Scalar
136 mx[px+"errors_processing_table"] = pm.Stats.Errors.Processing.Table
137 + mx[px+"errors_processing_licensing"] = pm.Stats.Errors.Processing.Licensing
138 }
139 }
140
src/go/plugin/go.d/collector/snmp/collector.go
+3
@@ -72,6 +72,7 @@ func New() *Collector {
72 seenProfiles: make(map[string]bool),
73
74 ifaceCache: newIfaceCache(),
75 + licensing: newLicensingIntegration(),
76
77 newPinger: pinger.New,
78 newSnmpClient: gosnmp.NewHandler,
@@ -81,6 +82,7 @@ func New() *Collector {
82 }
83
84 c.funcRouter = newFuncRouter(c.ifaceCache)
85 + c.licensing.registerFunction(c.funcRouter)
86
87 return c
88 }
@@ -98,6 +100,7 @@ type (
100 seenProfiles map[string]bool
101
102 ifaceCache *ifaceCache // interface metrics cache for functions
103 + licensing *licensingIntegration
104 funcRouter *funcRouter // function router for method handlers
105
106 pingClient pinger.Client
src/go/plugin/go.d/collector/snmp/collector_licensing_edge_test.go new
+103
@@ -0,0 +1,103 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "context"
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 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
16 + ddsnmpcollector "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector"
17 +)
18 +
19 +func TestCollector_Collect_LicensingAggregation_ReadsTypedRowsAndIgnoresPrivateMetrics(t *testing.T) {
20 + tests := map[string]struct {
21 + profileMetrics *ddsnmp.ProfileMetrics
22 + want map[string]int64
23 + }{
24 + "typed rows drive aggregate while private metrics are ignored": {
25 + profileMetrics: &ddsnmp.ProfileMetrics{
26 + Source: "noise-licensing.yaml",
27 + LicenseRows: []ddsnmp.LicenseRow{
28 + typedLicenseRow("healthy", "Healthy license", withState(0, "active")),
29 + },
30 + HiddenMetrics: []ddsnmp.Metric{
31 + {
32 + Name: "_private_metric",
33 + Value: 0,
34 + Tags: map[string]string{
35 + "state": "active",
36 + },
37 + },
38 + {
39 + Name: "_private_metric_total",
40 + Value: 123,
41 + Tags: map[string]string{
42 + "component": "noise",
43 + },
44 + },
45 + {
46 + Name: "_private_helper",
47 + Value: 999,
48 + Tags: map[string]string{
49 + "component": "helper",
50 + },
51 + },
52 + },
53 + },
54 + want: map[string]int64{
55 + metricIDLicenseStateHealthy: 1,
56 + metricIDLicenseStateInformational: 0,
57 + metricIDLicenseStateDegraded: 0,
58 + metricIDLicenseStateBroken: 0,
59 + metricIDLicenseStateIgnored: 0,
60 + },
61 + },
62 + }
63 +
64 + for name, tc := range tests {
65 + t.Run(name, func(t *testing.T) {
66 + mockCtl := gomock.NewController(t)
67 + defer mockCtl.Finish()
68 +
69 + mockSNMP := snmpmock.NewMockHandler(mockCtl)
70 + setMockClientInitExpect(mockSNMP)
71 + setMockClientSysInfoExpect(mockSNMP)
72 +
73 + collr := New()
74 + collr.Config = prepareV2Config()
75 + collr.CreateVnode = false
76 + collr.Ping.Enabled = false
77 + collr.snmpProfiles = []*ddsnmp.Profile{{}}
78 + collr.newSnmpClient = func() gosnmp.Handler { return mockSNMP }
79 + collr.newDdSnmpColl = func(ddsnmpcollector.Config) ddCollector {
80 + pm := tc.profileMetrics
81 + for i := range pm.HiddenMetrics {
82 + pm.HiddenMetrics[i].Profile = pm
83 + }
84 + return &mockDdSnmpCollector{pms: []*ddsnmp.ProfileMetrics{pm}}
85 + }
86 +
87 + require.NoError(t, collr.Init(context.Background()))
88 + _ = collr.Check(context.Background())
89 +
90 + got := collr.Collect(context.Background())
91 + require.NotNil(t, got)
92 +
93 + for id, want := range tc.want {
94 + assert.EqualValues(t, want, got[id])
95 + }
96 + assert.NotContains(t, got, metricIDLicenseRemainingTime)
97 + assert.NotContains(t, got, metricIDLicenseAuthorizationRemainingTime)
98 + assert.NotContains(t, got, metricIDLicenseCertificateRemainingTime)
99 + assert.NotContains(t, got, metricIDLicenseGraceRemainingTime)
100 + assert.NotContains(t, got, metricIDLicenseUsagePercent)
101 + })
102 + }
103 +}
src/go/plugin/go.d/collector/snmp/collector_test.go
+284 -65
@@ -367,26 +367,29 @@ func TestCollector_Collect(t *testing.T) {
367 },
368 want: map[string]int64{
369 // scalar → "snmp_device_prof_<name>"
370 - "snmp_device_prof_test_stats_errors_processing_scalar": 0,
371 - "snmp_device_prof_test_stats_errors_processing_table": 0,
372 - "snmp_device_prof_test_stats_errors_snmp": 0,
373 - "snmp_device_prof_test_stats_metrics_rows": 0,
374 - "snmp_device_prof_test_stats_metrics_scalar": 0,
375 - "snmp_device_prof_test_stats_metrics_table": 0,
376 - "snmp_device_prof_test_stats_metrics_tables": 0,
377 - "snmp_device_prof_test_stats_metrics_virtual": 0,
378 - "snmp_device_prof_test_stats_snmp_get_oids": 0,
379 - "snmp_device_prof_test_stats_snmp_get_requests": 0,
380 - "snmp_device_prof_test_stats_snmp_tables_cached": 0,
381 - "snmp_device_prof_test_stats_snmp_tables_walked": 0,
382 - "snmp_device_prof_test_stats_snmp_walk_pdus": 0,
383 - "snmp_device_prof_test_stats_snmp_walk_requests": 0,
384 - "snmp_device_prof_test_stats_table_cache_hits": 0,
385 - "snmp_device_prof_test_stats_table_cache_misses": 0,
386 - "snmp_device_prof_test_stats_timings_scalar": 0,
387 - "snmp_device_prof_test_stats_timings_table": 0,
388 - "snmp_device_prof_test_stats_timings_virtual": 0,
389 - "snmp_device_prof_uptime": 123,
370 + "snmp_device_prof_test_stats_errors_processing_scalar": 0,
371 + "snmp_device_prof_test_stats_errors_processing_table": 0,
372 + "snmp_device_prof_test_stats_errors_processing_licensing": 0,
373 + "snmp_device_prof_test_stats_errors_snmp": 0,
374 + "snmp_device_prof_test_stats_metrics_rows": 0,
375 + "snmp_device_prof_test_stats_metrics_licensing": 0,
376 + "snmp_device_prof_test_stats_metrics_scalar": 0,
377 + "snmp_device_prof_test_stats_metrics_table": 0,
378 + "snmp_device_prof_test_stats_metrics_tables": 0,
379 + "snmp_device_prof_test_stats_metrics_virtual": 0,
380 + "snmp_device_prof_test_stats_snmp_get_oids": 0,
381 + "snmp_device_prof_test_stats_snmp_get_requests": 0,
382 + "snmp_device_prof_test_stats_snmp_tables_cached": 0,
383 + "snmp_device_prof_test_stats_snmp_tables_walked": 0,
384 + "snmp_device_prof_test_stats_snmp_walk_pdus": 0,
385 + "snmp_device_prof_test_stats_snmp_walk_requests": 0,
386 + "snmp_device_prof_test_stats_table_cache_hits": 0,
387 + "snmp_device_prof_test_stats_table_cache_misses": 0,
388 + "snmp_device_prof_test_stats_timings_scalar": 0,
389 + "snmp_device_prof_test_stats_timings_table": 0,
390 + "snmp_device_prof_test_stats_timings_licensing": 0,
391 + "snmp_device_prof_test_stats_timings_virtual": 0,
392 + "snmp_device_prof_uptime": 123,
393 },
394 },
395 "collects table multivalue metric": {
@@ -425,27 +428,30 @@ func TestCollector_Collect(t *testing.T) {
428 want: map[string]int64{
429 // table key: "snmp_device_prof_<name>_<sorted tag values>_<subkey>"
430 // here tags = {"ifName":"eth0"} → key part becomes "_eth0"
428 - "snmp_device_prof_test_stats_errors_processing_scalar": 0,
429 - "snmp_device_prof_test_stats_errors_processing_table": 0,
430 - "snmp_device_prof_test_stats_errors_snmp": 0,
431 - "snmp_device_prof_test_stats_metrics_rows": 0,
432 - "snmp_device_prof_test_stats_metrics_scalar": 0,
433 - "snmp_device_prof_test_stats_metrics_table": 0,
434 - "snmp_device_prof_test_stats_metrics_tables": 0,
435 - "snmp_device_prof_test_stats_metrics_virtual": 0,
436 - "snmp_device_prof_test_stats_snmp_get_oids": 0,
437 - "snmp_device_prof_test_stats_snmp_get_requests": 0,
438 - "snmp_device_prof_test_stats_snmp_tables_cached": 0,
439 - "snmp_device_prof_test_stats_snmp_tables_walked": 0,
440 - "snmp_device_prof_test_stats_snmp_walk_pdus": 0,
441 - "snmp_device_prof_test_stats_snmp_walk_requests": 0,
442 - "snmp_device_prof_test_stats_table_cache_hits": 0,
443 - "snmp_device_prof_test_stats_table_cache_misses": 0,
444 - "snmp_device_prof_test_stats_timings_scalar": 0,
445 - "snmp_device_prof_test_stats_timings_table": 0,
446 - "snmp_device_prof_test_stats_timings_virtual": 0,
447 - "snmp_device_prof_if_octets_eth0_in": 1,
448 - "snmp_device_prof_if_octets_eth0_out": 2,
431 + "snmp_device_prof_test_stats_errors_processing_scalar": 0,
432 + "snmp_device_prof_test_stats_errors_processing_table": 0,
433 + "snmp_device_prof_test_stats_errors_processing_licensing": 0,
434 + "snmp_device_prof_test_stats_errors_snmp": 0,
435 + "snmp_device_prof_test_stats_metrics_rows": 0,
436 + "snmp_device_prof_test_stats_metrics_licensing": 0,
437 + "snmp_device_prof_test_stats_metrics_scalar": 0,
438 + "snmp_device_prof_test_stats_metrics_table": 0,
439 + "snmp_device_prof_test_stats_metrics_tables": 0,
440 + "snmp_device_prof_test_stats_metrics_virtual": 0,
441 + "snmp_device_prof_test_stats_snmp_get_oids": 0,
442 + "snmp_device_prof_test_stats_snmp_get_requests": 0,
443 + "snmp_device_prof_test_stats_snmp_tables_cached": 0,
444 + "snmp_device_prof_test_stats_snmp_tables_walked": 0,
445 + "snmp_device_prof_test_stats_snmp_walk_pdus": 0,
446 + "snmp_device_prof_test_stats_snmp_walk_requests": 0,
447 + "snmp_device_prof_test_stats_table_cache_hits": 0,
448 + "snmp_device_prof_test_stats_table_cache_misses": 0,
449 + "snmp_device_prof_test_stats_timings_scalar": 0,
450 + "snmp_device_prof_test_stats_timings_table": 0,
451 + "snmp_device_prof_test_stats_timings_licensing": 0,
452 + "snmp_device_prof_test_stats_timings_virtual": 0,
453 + "snmp_device_prof_if_octets_eth0_in": 1,
454 + "snmp_device_prof_if_octets_eth0_out": 2,
455 },
456 },
457 }
@@ -600,30 +606,33 @@ func TestCollector_CollectMixedModeAllowsNilContext(t *testing.T) {
606 got := collr.Collect(nil)
607
608 assert.Equal(t, map[string]int64{
603 - "snmp_device_prof_test_stats_errors_processing_scalar": 0,
604 - "snmp_device_prof_test_stats_errors_processing_table": 0,
605 - "snmp_device_prof_test_stats_errors_snmp": 0,
606 - "snmp_device_prof_test_stats_metrics_rows": 0,
607 - "snmp_device_prof_test_stats_metrics_scalar": 0,
608 - "snmp_device_prof_test_stats_metrics_table": 0,
609 - "snmp_device_prof_test_stats_metrics_tables": 0,
610 - "snmp_device_prof_test_stats_metrics_virtual": 0,
611 - "snmp_device_prof_test_stats_snmp_get_oids": 0,
612 - "snmp_device_prof_test_stats_snmp_get_requests": 0,
613 - "snmp_device_prof_test_stats_snmp_tables_cached": 0,
614 - "snmp_device_prof_test_stats_snmp_tables_walked": 0,
615 - "snmp_device_prof_test_stats_snmp_walk_pdus": 0,
616 - "snmp_device_prof_test_stats_snmp_walk_requests": 0,
617 - "snmp_device_prof_test_stats_table_cache_hits": 0,
618 - "snmp_device_prof_test_stats_table_cache_misses": 0,
619 - "snmp_device_prof_test_stats_timings_scalar": 0,
620 - "snmp_device_prof_test_stats_timings_table": 0,
621 - "snmp_device_prof_test_stats_timings_virtual": 0,
622 - "snmp_device_prof_uptime": 123,
623 - "ping_rtt_min": (10 * time.Millisecond).Microseconds(),
624 - "ping_rtt_max": (20 * time.Millisecond).Microseconds(),
625 - "ping_rtt_avg": (15 * time.Millisecond).Microseconds(),
626 - "ping_rtt_stddev": (5 * time.Millisecond).Microseconds(),
609 + "snmp_device_prof_test_stats_errors_processing_scalar": 0,
610 + "snmp_device_prof_test_stats_errors_processing_table": 0,
611 + "snmp_device_prof_test_stats_errors_processing_licensing": 0,
612 + "snmp_device_prof_test_stats_errors_snmp": 0,
613 + "snmp_device_prof_test_stats_metrics_rows": 0,
614 + "snmp_device_prof_test_stats_metrics_licensing": 0,
615 + "snmp_device_prof_test_stats_metrics_scalar": 0,
616 + "snmp_device_prof_test_stats_metrics_table": 0,
617 + "snmp_device_prof_test_stats_metrics_tables": 0,
618 + "snmp_device_prof_test_stats_metrics_virtual": 0,
619 + "snmp_device_prof_test_stats_snmp_get_oids": 0,
620 + "snmp_device_prof_test_stats_snmp_get_requests": 0,
621 + "snmp_device_prof_test_stats_snmp_tables_cached": 0,
622 + "snmp_device_prof_test_stats_snmp_tables_walked": 0,
623 + "snmp_device_prof_test_stats_snmp_walk_pdus": 0,
624 + "snmp_device_prof_test_stats_snmp_walk_requests": 0,
625 + "snmp_device_prof_test_stats_table_cache_hits": 0,
626 + "snmp_device_prof_test_stats_table_cache_misses": 0,
627 + "snmp_device_prof_test_stats_timings_scalar": 0,
628 + "snmp_device_prof_test_stats_timings_table": 0,
629 + "snmp_device_prof_test_stats_timings_licensing": 0,
630 + "snmp_device_prof_test_stats_timings_virtual": 0,
631 + "snmp_device_prof_uptime": 123,
632 + "ping_rtt_min": (10 * time.Millisecond).Microseconds(),
633 + "ping_rtt_max": (20 * time.Millisecond).Microseconds(),
634 + "ping_rtt_avg": (15 * time.Millisecond).Microseconds(),
635 + "ping_rtt_stddev": (5 * time.Millisecond).Microseconds(),
636 }, got)
637 }
638
@@ -693,6 +702,216 @@ func pingNoReplySample(host string) pinger.Sample {
702 }
703 }
704
705 +func TestCollector_Collect_LicensingAggregation(t *testing.T) {
706 + tests := map[string]struct {
707 + source string
708 + rows func(time.Time) []ddsnmp.LicenseRow
709 + assert func(*testing.T, map[string]int64, time.Time)
710 + }{
711 + "checkpoint degraded row with expiry and usage": {
712 + source: "checkpoint.yaml",
713 + rows: func(now time.Time) []ddsnmp.LicenseRow {
714 + expiry := now.Add(48 * time.Hour).Unix()
715 + return []ddsnmp.LicenseRow{
716 + typedLicenseRow("17", "Application Control",
717 + withState(1, "about-to-expire"),
718 + withExpiry(expiry),
719 + withUsage(95),
720 + withCapacity(100),
721 + ),
722 + }
723 + },
724 + assert: func(t *testing.T, got map[string]int64, start time.Time) {
725 + assert.EqualValues(t, 0, got[metricIDLicenseStateHealthy])
726 + assert.EqualValues(t, 0, got[metricIDLicenseStateInformational])
727 + assert.EqualValues(t, 1, got[metricIDLicenseStateDegraded])
728 + assert.EqualValues(t, 0, got[metricIDLicenseStateBroken])
729 + assert.EqualValues(t, 0, got[metricIDLicenseStateIgnored])
730 + assert.EqualValues(t, 95, got[metricIDLicenseUsagePercent])
731 + expectedRemaining := start.Add(48*time.Hour).Unix() - start.Unix()
732 + assert.GreaterOrEqual(t, got[metricIDLicenseRemainingTime], expectedRemaining-30)
733 + assert.LessOrEqual(t, got[metricIDLicenseRemainingTime], expectedRemaining)
734 + assert.Contains(t, got, "snmp_device_prof_checkpoint_stats_metrics_table")
735 + },
736 + },
737 + "cisco smart partial data": {
738 + source: "cisco.yaml",
739 + rows: func(now time.Time) []ddsnmp.LicenseRow {
740 + authExpiry := now.Add(48 * time.Hour).Unix()
741 + certExpiry := now.Add(72 * time.Hour).Unix()
742 + return []ddsnmp.LicenseRow{
743 + typedLicenseRow("smart_authorization_state", "Smart Licensing authorization state",
744 + withState(0, ""),
745 + ),
746 + typedLicenseRow("smart_authorization_expiry", "Smart Licensing authorization",
747 + func(row *ddsnmp.LicenseRow) {
748 + row.Authorization.Has = true
749 + row.Authorization.Timestamp = authExpiry
750 + row.Authorization.SourceOID = "ciscoSlaAuthExpireTime"
751 + },
752 + ),
753 + typedLicenseRow("smart_id_certificate_expiry", "Smart Licensing ID certificate",
754 + func(row *ddsnmp.LicenseRow) {
755 + row.Certificate.Has = true
756 + row.Certificate.Timestamp = certExpiry
757 + row.Certificate.SourceOID = "ciscoSlaNextCertificateExpireTime"
758 + },
759 + ),
760 + typedLicenseRow("dna_advantage", "network-advantage",
761 + withState(2, "authorization_expired"),
762 + withUsage(42),
763 + ),
764 + }
765 + },
766 + assert: func(t *testing.T, got map[string]int64, _ time.Time) {
767 + assert.EqualValues(t, 3, got[metricIDLicenseStateHealthy])
768 + assert.EqualValues(t, 0, got[metricIDLicenseStateInformational])
769 + assert.EqualValues(t, 0, got[metricIDLicenseStateDegraded])
770 + assert.EqualValues(t, 1, got[metricIDLicenseStateBroken])
771 + assert.EqualValues(t, 0, got[metricIDLicenseStateIgnored])
772 + assert.GreaterOrEqual(t, got[metricIDLicenseAuthorizationRemainingTime], int64((48*time.Hour/time.Second)-5))
773 + assert.LessOrEqual(t, got[metricIDLicenseAuthorizationRemainingTime], int64(48*time.Hour/time.Second))
774 + assert.GreaterOrEqual(t, got[metricIDLicenseCertificateRemainingTime], int64((72*time.Hour/time.Second)-5))
775 + assert.LessOrEqual(t, got[metricIDLicenseCertificateRemainingTime], int64(72*time.Hour/time.Second))
776 + assert.NotContains(t, got, metricIDLicenseRemainingTime)
777 + assert.NotContains(t, got, metricIDLicenseGraceRemainingTime)
778 + assert.NotContains(t, got, metricIDLicenseUsagePercent)
779 + },
780 + },
781 + "cisco traditional usage and grace": {
782 + source: "cisco.yaml",
783 + rows: func(now time.Time) []ddsnmp.LicenseRow {
784 + securityExpiry := now.Add(72 * time.Hour).Unix()
785 + return []ddsnmp.LicenseRow{
786 + typedLicenseRow("17", "SECURITYK9",
787 + withRawState("in_use"),
788 + withExpiry(securityExpiry),
789 + withCapacity(100),
790 + withAvailable(15),
791 + ),
792 + typedLicenseRow("23", "APPXK9",
793 + withState(2, "usage_count_consumed"),
794 + withGraceRemaining(3600),
795 + withCapacity(10),
796 + withAvailable(0),
797 + ),
798 + }
799 + },
800 + assert: func(t *testing.T, got map[string]int64, _ time.Time) {
801 + assert.EqualValues(t, 1, got[metricIDLicenseStateHealthy])
802 + assert.EqualValues(t, 0, got[metricIDLicenseStateInformational])
803 + assert.EqualValues(t, 0, got[metricIDLicenseStateDegraded])
804 + assert.EqualValues(t, 1, got[metricIDLicenseStateBroken])
805 + assert.EqualValues(t, 0, got[metricIDLicenseStateIgnored])
806 + assert.EqualValues(t, 100, got[metricIDLicenseUsagePercent])
807 + assert.GreaterOrEqual(t, got[metricIDLicenseRemainingTime], int64((72*time.Hour/time.Second)-5))
808 + assert.LessOrEqual(t, got[metricIDLicenseRemainingTime], int64(72*time.Hour/time.Second))
809 + assert.GreaterOrEqual(t, got[metricIDLicenseGraceRemainingTime], int64((time.Hour/time.Second)-5))
810 + assert.LessOrEqual(t, got[metricIDLicenseGraceRemainingTime], int64(time.Hour/time.Second))
811 + },
812 + },
813 + "mixed rows select worst aggregate signals": {
814 + source: "mixed-licensing.yaml",
815 + rows: func(now time.Time) []ddsnmp.LicenseRow {
816 + perpetualExpiry := now.Add(30 * time.Minute).Unix()
817 + earliestRealExpiry := now.Add(6 * time.Hour).Unix()
818 + authExpiry := now.Add(30 * time.Hour).Unix()
819 + certExpiry := now.Add(20 * time.Hour).Unix()
820 + graceExpiry := now.Add(10 * time.Hour).Unix()
821 +
822 + return []ddsnmp.LicenseRow{
823 + typedLicenseRow("perpetual", "Perpetual base",
824 + withRawState("active"),
825 + withExpiry(perpetualExpiry),
826 + withUsage(50),
827 + withCapacity(100),
828 + withPerpetual(),
829 + ),
830 + typedLicenseRow("soonest_expiring", "Threat prevention",
831 + withRawState("about-to-expire"),
832 + withExpiry(earliestRealExpiry),
833 + withUsage(90),
834 + withCapacity(100),
835 + ),
836 + typedLicenseRow("auth", "Smart auth",
837 + func(row *ddsnmp.LicenseRow) {
838 + row.Authorization.Has = true
839 + row.Authorization.Timestamp = authExpiry
840 + row.Authorization.SourceOID = "auth_timer"
841 + },
842 + ),
843 + typedLicenseRow("cert", "Smart cert",
844 + func(row *ddsnmp.LicenseRow) {
845 + row.Certificate.Has = true
846 + row.Certificate.Timestamp = certExpiry
847 + row.Certificate.SourceOID = "cert_timer"
848 + },
849 + ),
850 + typedLicenseRow("grace", "Eval grace",
851 + withRawState("evaluation"),
852 + func(row *ddsnmp.LicenseRow) {
853 + row.Grace.Has = true
854 + row.Grace.Timestamp = graceExpiry
855 + },
856 + ),
857 + typedLicenseRow("broken", "Broken feature", withState(2, "")),
858 + typedLicenseRow("unlimited", "Unlimited pool", withUsagePercent(100), withUnlimited()),
859 + }
860 + },
861 + assert: func(t *testing.T, got map[string]int64, _ time.Time) {
862 + assert.EqualValues(t, 4, got[metricIDLicenseStateHealthy])
863 + assert.EqualValues(t, 0, got[metricIDLicenseStateInformational])
864 + assert.EqualValues(t, 2, got[metricIDLicenseStateDegraded])
865 + assert.EqualValues(t, 1, got[metricIDLicenseStateBroken])
866 + assert.EqualValues(t, 0, got[metricIDLicenseStateIgnored])
867 + assert.EqualValues(t, 90, got[metricIDLicenseUsagePercent])
868 + assert.GreaterOrEqual(t, got[metricIDLicenseRemainingTime], int64((6*time.Hour/time.Second)-5))
869 + assert.LessOrEqual(t, got[metricIDLicenseRemainingTime], int64(6*time.Hour/time.Second))
870 + assert.GreaterOrEqual(t, got[metricIDLicenseAuthorizationRemainingTime], int64((30*time.Hour/time.Second)-5))
871 + assert.LessOrEqual(t, got[metricIDLicenseAuthorizationRemainingTime], int64(30*time.Hour/time.Second))
872 + assert.GreaterOrEqual(t, got[metricIDLicenseCertificateRemainingTime], int64((20*time.Hour/time.Second)-5))
873 + assert.LessOrEqual(t, got[metricIDLicenseCertificateRemainingTime], int64(20*time.Hour/time.Second))
874 + assert.GreaterOrEqual(t, got[metricIDLicenseGraceRemainingTime], int64((10*time.Hour/time.Second)-5))
875 + assert.LessOrEqual(t, got[metricIDLicenseGraceRemainingTime], int64(10*time.Hour/time.Second))
876 + },
877 + },
878 + }
879 +
880 + for name, tc := range tests {
881 + t.Run(name, func(t *testing.T) {
882 + mockCtl := gomock.NewController(t)
883 + defer mockCtl.Finish()
884 +
885 + mockSNMP := snmpmock.NewMockHandler(mockCtl)
886 + setMockClientInitExpect(mockSNMP)
887 + setMockClientSysInfoExpect(mockSNMP)
888 +
889 + now := time.Now().UTC()
890 + collr := New()
891 + collr.Config = prepareV2Config()
892 + collr.CreateVnode = false
893 + collr.Ping.Enabled = false
894 + collr.snmpProfiles = []*ddsnmp.Profile{{}}
895 + collr.newSnmpClient = func() gosnmp.Handler { return mockSNMP }
896 + collr.newDdSnmpColl = func(ddsnmpcollector.Config) ddCollector {
897 + pm := &ddsnmp.ProfileMetrics{
898 + Source: tc.source,
899 + LicenseRows: tc.rows(now),
900 + }
901 + return &mockDdSnmpCollector{pms: []*ddsnmp.ProfileMetrics{pm}}
902 + }
903 +
904 + require.NoError(t, collr.Init(context.Background()))
905 + _ = collr.Check(context.Background())
906 +
907 + start := time.Now().UTC()
908 + got := collr.Collect(context.Background())
909 + require.NotNil(t, got)
910 + tc.assert(t, got, start)
911 + })
912 + }
913 +}
914 +
915 type mockDdSnmpCollector struct {
916 pms []*ddsnmp.ProfileMetrics
917 meta map[string]ddsnmp.MetaTag
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/consumers.go
+4 -8
@@ -9,8 +9,9 @@ import "slices"
9 type ProfileConsumer string
10
11 const (
12 - ConsumerMetrics ProfileConsumer = "metrics"
13 - ConsumerTopology ProfileConsumer = "topology"
12 + ConsumerMetrics ProfileConsumer = "metrics"
13 + ConsumerTopology ProfileConsumer = "topology"
14 + ConsumerLicensing ProfileConsumer = "licensing"
15 )
16
17 type ConsumerSet []ProfileConsumer
@@ -20,12 +21,7 @@ func (s ConsumerSet) Clone() ConsumerSet {
21 }
22
23 func (s ConsumerSet) Contains(consumer ProfileConsumer) bool {
23 - for _, c := range s {
24 - if c == consumer {
25 - return true
26 - }
27 - }
28 - return false
24 + return slices.Contains(s, consumer)
25 }
26
27 func (s ConsumerSet) IsEmpty() bool {
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/licensing.go new
+322
@@ -0,0 +1,322 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddprofiledefinition
4 +
5 +import (
6 + "slices"
7 + "strings"
8 +)
9 +
10 +type LicenseSignalKind string
11 +
12 +const (
13 + LicenseSignalExpiryTimestamp LicenseSignalKind = "expiry_timestamp"
14 + LicenseSignalExpiryRemaining LicenseSignalKind = "expiry_remaining"
15 + LicenseSignalAuthorizationTimestamp LicenseSignalKind = "authorization_timestamp"
16 + LicenseSignalAuthorizationRemaining LicenseSignalKind = "authorization_remaining"
17 + LicenseSignalCertificateTimestamp LicenseSignalKind = "certificate_timestamp"
18 + LicenseSignalCertificateRemaining LicenseSignalKind = "certificate_remaining"
19 + LicenseSignalGraceTimestamp LicenseSignalKind = "grace_timestamp"
20 + LicenseSignalGraceRemaining LicenseSignalKind = "grace_remaining"
21 + LicenseSignalUsageUsed LicenseSignalKind = "usage_used"
22 + LicenseSignalUsageCapacity LicenseSignalKind = "usage_capacity"
23 + LicenseSignalUsageAvailable LicenseSignalKind = "usage_available"
24 + LicenseSignalUsagePercent LicenseSignalKind = "usage_percent"
25 + LicenseSignalStateSeverity LicenseSignalKind = "state_severity"
26 +)
27 +
28 +var validLicenseSignalKinds = map[LicenseSignalKind]struct{}{
29 + LicenseSignalExpiryTimestamp: {},
30 + LicenseSignalExpiryRemaining: {},
31 + LicenseSignalAuthorizationTimestamp: {},
32 + LicenseSignalAuthorizationRemaining: {},
33 + LicenseSignalCertificateTimestamp: {},
34 + LicenseSignalCertificateRemaining: {},
35 + LicenseSignalGraceTimestamp: {},
36 + LicenseSignalGraceRemaining: {},
37 + LicenseSignalUsageUsed: {},
38 + LicenseSignalUsageCapacity: {},
39 + LicenseSignalUsageAvailable: {},
40 + LicenseSignalUsagePercent: {},
41 + LicenseSignalStateSeverity: {},
42 +}
43 +
44 +func IsValidLicenseSignalKind(kind LicenseSignalKind) bool {
45 + _, ok := validLicenseSignalKinds[kind]
46 + return ok
47 +}
48 +
49 +type LicenseSentinelPolicy string
50 +
51 +const (
52 + LicenseSentinelTimerZeroOrNegative LicenseSentinelPolicy = "timer_zero_or_negative"
53 + LicenseSentinelTimerU32Max LicenseSentinelPolicy = "timer_u32_max"
54 + LicenseSentinelTimerPre1971 LicenseSentinelPolicy = "timer_pre_1971"
55 +)
56 +
57 +var validLicenseSentinelPolicies = map[LicenseSentinelPolicy]struct{}{
58 + LicenseSentinelTimerZeroOrNegative: {},
59 + LicenseSentinelTimerU32Max: {},
60 + LicenseSentinelTimerPre1971: {},
61 +}
62 +
63 +func IsValidLicenseSentinelPolicy(policy LicenseSentinelPolicy) bool {
64 + _, ok := validLicenseSentinelPolicies[policy]
65 + return ok
66 +}
67 +
68 +type LicenseStatePolicy string
69 +
70 +const (
71 + LicenseStatePolicyDefault LicenseStatePolicy = "default"
72 + LicenseStatePolicySophos LicenseStatePolicy = "sophos"
73 +)
74 +
75 +var validLicenseStatePolicies = map[LicenseStatePolicy]struct{}{
76 + LicenseStatePolicyDefault: {},
77 + LicenseStatePolicySophos: {},
78 +}
79 +
80 +func IsValidLicenseStatePolicy(policy LicenseStatePolicy) bool {
81 + _, ok := validLicenseStatePolicies[policy]
82 + return ok
83 +}
84 +
85 +type LicensingConfig struct {
86 + OriginProfileID string `yaml:"-" json:"-"`
87 + ID string `yaml:"id,omitempty" json:"id,omitempty"`
88 + MIB string `yaml:"MIB,omitempty" json:"MIB,omitempty"`
89 + Table SymbolConfig `yaml:"table,omitempty" json:"table"`
90 +
91 + Identity LicenseIdentityConfig `yaml:"identity,omitempty" json:"identity"`
92 + Descriptors LicenseDescriptorsConfig `yaml:"descriptors,omitempty" json:"descriptors"`
93 + State LicenseStateConfig `yaml:"state,omitempty" json:"state"`
94 + Signals LicenseSignalsConfig `yaml:"signals,omitempty" json:"signals"`
95 +
96 + StaticTags []StaticMetricTagConfig `yaml:"static_tags,omitempty" json:"-"`
97 + MetricTags MetricTagConfigList `yaml:"metric_tags,omitempty" json:"metric_tags,omitempty"`
98 +}
99 +
100 +func (c LicensingConfig) Clone() LicensingConfig {
101 + return LicensingConfig{
102 + OriginProfileID: c.OriginProfileID,
103 + ID: c.ID,
104 + MIB: c.MIB,
105 + Table: c.Table.Clone(),
106 + Identity: c.Identity.Clone(),
107 + Descriptors: c.Descriptors.Clone(),
108 + State: c.State.Clone(),
109 + Signals: c.Signals.Clone(),
110 + StaticTags: slices.Clone(c.StaticTags),
111 + MetricTags: cloneSlice(c.MetricTags),
112 + }
113 +}
114 +
115 +type LicenseIdentityConfig struct {
116 + ID LicenseValueConfig `yaml:"id,omitempty" json:"id"`
117 + Name LicenseValueConfig `yaml:"name,omitempty" json:"name"`
118 + Feature LicenseValueConfig `yaml:"feature,omitempty" json:"feature"`
119 + Component LicenseValueConfig `yaml:"component,omitempty" json:"component"`
120 +}
121 +
122 +func (c LicenseIdentityConfig) Clone() LicenseIdentityConfig {
123 + return LicenseIdentityConfig{
124 + ID: c.ID.Clone(),
125 + Name: c.Name.Clone(),
126 + Feature: c.Feature.Clone(),
127 + Component: c.Component.Clone(),
128 + }
129 +}
130 +
131 +type LicenseDescriptorsConfig struct {
132 + Type LicenseValueConfig `yaml:"type,omitempty" json:"type"`
133 + Impact LicenseValueConfig `yaml:"impact,omitempty" json:"impact"`
134 + Perpetual LicenseValueConfig `yaml:"perpetual,omitempty" json:"perpetual"`
135 + Unlimited LicenseValueConfig `yaml:"unlimited,omitempty" json:"unlimited"`
136 +}
137 +
138 +func (c LicenseDescriptorsConfig) Clone() LicenseDescriptorsConfig {
139 + return LicenseDescriptorsConfig{
140 + Type: c.Type.Clone(),
141 + Impact: c.Impact.Clone(),
142 + Perpetual: c.Perpetual.Clone(),
143 + Unlimited: c.Unlimited.Clone(),
144 + }
145 +}
146 +
147 +type LicenseStateConfig struct {
148 + LicenseValueConfig `yaml:",inline" json:",inline"`
149 + Policy LicenseStatePolicy `yaml:"policy,omitempty" json:"policy,omitempty"`
150 +}
151 +
152 +func (c LicenseStateConfig) Clone() LicenseStateConfig {
153 + return LicenseStateConfig{
154 + LicenseValueConfig: c.LicenseValueConfig.Clone(),
155 + Policy: c.Policy,
156 + }
157 +}
158 +
159 +type LicenseSignalsConfig struct {
160 + Expiry LicenseTimerSignalsConfig `yaml:"expiry,omitempty" json:"expiry"`
161 + Authorization LicenseTimerSignalsConfig `yaml:"authorization,omitempty" json:"authorization"`
162 + Certificate LicenseTimerSignalsConfig `yaml:"certificate,omitempty" json:"certificate"`
163 + Grace LicenseTimerSignalsConfig `yaml:"grace,omitempty" json:"grace"`
164 + Usage LicenseUsageSignalsConfig `yaml:"usage,omitempty" json:"usage"`
165 +}
166 +
167 +func (c LicenseSignalsConfig) Clone() LicenseSignalsConfig {
168 + return LicenseSignalsConfig{
169 + Expiry: c.Expiry.Clone(),
170 + Authorization: c.Authorization.Clone(),
171 + Certificate: c.Certificate.Clone(),
172 + Grace: c.Grace.Clone(),
173 + Usage: c.Usage.Clone(),
174 + }
175 +}
176 +
177 +type LicenseTimerSignalsConfig struct {
178 + LicenseValueConfig `yaml:",inline" json:",inline"`
179 + Timestamp LicenseValueConfig `yaml:"timestamp,omitempty" json:"timestamp"`
180 + Remaining LicenseValueConfig `yaml:"remaining,omitempty" json:"remaining"`
181 +}
182 +
183 +func (c LicenseTimerSignalsConfig) Clone() LicenseTimerSignalsConfig {
184 + return LicenseTimerSignalsConfig{
185 + LicenseValueConfig: c.LicenseValueConfig.Clone(),
186 + Timestamp: c.Timestamp.Clone(),
187 + Remaining: c.Remaining.Clone(),
188 + }
189 +}
190 +
191 +type LicenseUsageSignalsConfig struct {
192 + Used LicenseValueConfig `yaml:"used,omitempty" json:"used"`
193 + Capacity LicenseValueConfig `yaml:"capacity,omitempty" json:"capacity"`
194 + Available LicenseValueConfig `yaml:"available,omitempty" json:"available"`
195 + Percent LicenseValueConfig `yaml:"percent,omitempty" json:"percent"`
196 +}
197 +
198 +func (c LicenseUsageSignalsConfig) Clone() LicenseUsageSignalsConfig {
199 + return LicenseUsageSignalsConfig{
200 + Used: c.Used.Clone(),
201 + Capacity: c.Capacity.Clone(),
202 + Available: c.Available.Clone(),
203 + Percent: c.Percent.Clone(),
204 + }
205 +}
206 +
207 +type LicenseValueConfig struct {
208 + Value string `yaml:"value,omitempty" json:"value,omitempty"`
209 + From string `yaml:"from,omitempty" json:"from,omitempty"`
210 +
211 + Index uint `yaml:"index,omitempty" json:"index,omitempty"`
212 + IndexTransform []MetricIndexTransform `yaml:"index_transform,omitempty" json:"index_transform,omitempty"`
213 +
214 + Symbol SymbolConfig `yaml:"symbol,omitempty" json:"symbol"`
215 + OID string `yaml:"OID,omitempty" json:"OID,omitempty" jsonschema:"-"`
216 + Name string `yaml:"name,omitempty" json:"name,omitempty" jsonschema:"-"`
217 +
218 + Format string `yaml:"format,omitempty" json:"format,omitempty"`
219 + Mapping MappingConfig `yaml:"mapping,omitempty" json:"mapping"`
220 + Sentinel []LicenseSentinelPolicy `yaml:"sentinel,omitempty" json:"sentinel,omitempty"`
221 + Kind LicenseSignalKind `yaml:"kind,omitempty" json:"kind,omitempty"`
222 +}
223 +
224 +func (c LicenseValueConfig) IsSet() bool {
225 + return c.Value != "" ||
226 + c.From != "" ||
227 + c.Index != 0 ||
228 + len(c.IndexTransform) > 0 ||
229 + c.Symbol.OID != "" ||
230 + c.Symbol.Name != "" ||
231 + c.OID != "" ||
232 + c.Name != "" ||
233 + c.Format != "" ||
234 + c.Mapping.HasItems() ||
235 + c.Mapping.Mode != "" ||
236 + len(c.Sentinel) > 0 ||
237 + c.Kind != ""
238 +}
239 +
240 +func (c LicenseValueConfig) Clone() LicenseValueConfig {
241 + return LicenseValueConfig{
242 + Value: c.Value,
243 + From: c.From,
244 + Index: c.Index,
245 + IndexTransform: slices.Clone(c.IndexTransform),
246 + Symbol: c.Symbol.Clone(),
247 + OID: c.OID,
248 + Name: c.Name,
249 + Format: c.Format,
250 + Mapping: c.Mapping.Clone(),
251 + Sentinel: slices.Clone(c.Sentinel),
252 + Kind: c.Kind,
253 + }
254 +}
255 +
256 +type LicenseSignalValueRef struct {
257 + Kind LicenseSignalKind
258 + Value LicenseValueConfig
259 +}
260 +
261 +func LicenseSignalValueRefs(row LicensingConfig) []LicenseSignalValueRef {
262 + var values []LicenseSignalValueRef
263 + add := func(kind LicenseSignalKind, value LicenseValueConfig) {
264 + values = append(values, LicenseSignalValueRef{Kind: kind, Value: value})
265 + }
266 + add(LicenseSignalStateSeverity, row.State.LicenseValueConfig)
267 + addLicenseTimerSignalValueRefs(row.Signals.Expiry, LicenseSignalExpiryTimestamp, LicenseSignalExpiryRemaining, add)
268 + addLicenseTimerSignalValueRefs(row.Signals.Authorization, LicenseSignalAuthorizationTimestamp, LicenseSignalAuthorizationRemaining, add)
269 + addLicenseTimerSignalValueRefs(row.Signals.Certificate, LicenseSignalCertificateTimestamp, LicenseSignalCertificateRemaining, add)
270 + addLicenseTimerSignalValueRefs(row.Signals.Grace, LicenseSignalGraceTimestamp, LicenseSignalGraceRemaining, add)
271 + add(LicenseSignalUsageUsed, row.Signals.Usage.Used)
272 + add(LicenseSignalUsageCapacity, row.Signals.Usage.Capacity)
273 + add(LicenseSignalUsageAvailable, row.Signals.Usage.Available)
274 + add(LicenseSignalUsagePercent, row.Signals.Usage.Percent)
275 + return values
276 +}
277 +
278 +func addLicenseTimerSignalValueRefs(cfg LicenseTimerSignalsConfig, timestampKind, remainingKind LicenseSignalKind, add func(LicenseSignalKind, LicenseValueConfig)) {
279 + add(timestampKind, cfg.LicenseValueConfig)
280 + add(timestampKind, cfg.Timestamp)
281 + add(remainingKind, cfg.Remaining)
282 +}
283 +
284 +func LicenseStructuralIdentity(row LicensingConfig) string {
285 + origin := row.OriginProfileID
286 + if origin == "" {
287 + origin = "<profile>"
288 + }
289 + return strings.Join([]string{origin, LicenseMergeIdentity(row)}, "|")
290 +}
291 +
292 +func LicenseMergeIdentity(row LicensingConfig) string {
293 + if row.Table.OID != "" {
294 + return strings.Join([]string{"table", TrimLicenseOID(row.Table.OID)}, "|")
295 + }
296 + if row.ID != "" {
297 + return strings.Join([]string{"scalar-group", row.ID}, "|")
298 + }
299 + for _, sig := range LicenseSignalValueRefs(row) {
300 + if oid := LicenseValueSourceOID(sig.Value); oid != "" {
301 + return strings.Join([]string{"scalar", TrimLicenseOID(oid)}, "|")
302 + }
303 + }
304 + return strings.Join([]string{"scalar", "<missing-source>"}, "|")
305 +}
306 +
307 +func LicenseValueSourceOID(value LicenseValueConfig) string {
308 + switch {
309 + case value.From != "":
310 + return value.From
311 + case value.Symbol.OID != "":
312 + return value.Symbol.OID
313 + case value.OID != "":
314 + return value.OID
315 + default:
316 + return ""
317 + }
318 +}
319 +
320 +func TrimLicenseOID(oid string) string {
321 + return strings.TrimPrefix(strings.TrimSpace(oid), ".")
322 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/licensing_test.go new
+439
@@ -0,0 +1,439 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddprofiledefinition
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/stretchr/testify/assert"
9 + "github.com/stretchr/testify/require"
10 + "gopkg.in/yaml.v2"
11 +)
12 +
13 +func TestProfileDefinition_UnmarshalLicensing(t *testing.T) {
14 + var profile ProfileDefinition
15 +
16 + err := yaml.Unmarshal([]byte(`
17 +metric_tags:
18 + - tag: vendor
19 + consumers: [licensing]
20 + symbol:
21 + OID: 1.3.6.1.2.1.1.1.0
22 + name: sysDescr
23 +licensing:
24 + - id: sophos-base-firewall
25 + MIB: SFOS-FIREWALL-MIB
26 + identity:
27 + id: { value: base_firewall }
28 + name: { value: Base Firewall }
29 + descriptors:
30 + type: { value: subscription }
31 + state:
32 + from: 1.3.6.1.4.1.2604.5.1.5.1.1.0
33 + policy: sophos
34 + mapping:
35 + items: { 0: ignored, 1: healthy, 2: broken }
36 + signals:
37 + expiry:
38 + from: 1.3.6.1.4.1.2604.5.1.5.1.2.0
39 + format: text_date
40 + sentinel: [timer_zero_or_negative]
41 +`), &profile)
42 +
43 + require.NoError(t, err)
44 + require.Len(t, profile.Licensing, 1)
45 + assert.Equal(t, "sophos-base-firewall", profile.Licensing[0].ID)
46 + assert.Equal(t, "SFOS-FIREWALL-MIB", profile.Licensing[0].MIB)
47 + assert.Equal(t, "base_firewall", profile.Licensing[0].Identity.ID.Value)
48 + assert.Equal(t, LicenseStatePolicySophos, profile.Licensing[0].State.Policy)
49 + assert.Equal(t, "1.3.6.1.4.1.2604.5.1.5.1.2.0", profile.Licensing[0].Signals.Expiry.From)
50 + assert.Equal(t, "text_date", profile.Licensing[0].Signals.Expiry.Format)
51 + assert.Equal(t, []LicenseSentinelPolicy{LicenseSentinelTimerZeroOrNegative}, profile.Licensing[0].Signals.Expiry.Sentinel)
52 + require.Len(t, profile.MetricTags, 1)
53 + assert.Equal(t, ConsumerSet{ConsumerLicensing}, profile.MetricTags[0].Consumers)
54 +}
55 +
56 +func TestProfileDefinition_CloneLicensing(t *testing.T) {
57 + profile := &ProfileDefinition{
58 + Licensing: []LicensingConfig{
59 + {
60 + OriginProfileID: "_vendor-licensing.yaml",
61 + ID: "row",
62 + Identity: LicenseIdentityConfig{
63 + ID: LicenseValueConfig{Value: "license-1"},
64 + },
65 + State: LicenseStateConfig{
66 + LicenseValueConfig: LicenseValueConfig{
67 + Symbol: SymbolConfig{
68 + OID: "1.2.3.0",
69 + Name: "licenseState",
70 + Mapping: NewExactMapping(map[string]string{
71 + "1": "healthy",
72 + }),
73 + },
74 + },
75 + Policy: LicenseStatePolicyDefault,
76 + },
77 + Signals: LicenseSignalsConfig{
78 + Expiry: LicenseTimerSignalsConfig{
79 + LicenseValueConfig: LicenseValueConfig{
80 + From: "1.2.4.0",
81 + Sentinel: []LicenseSentinelPolicy{LicenseSentinelTimerU32Max},
82 + },
83 + },
84 + },
85 + MetricTags: MetricTagConfigList{
86 + {Tag: "license_component", IndexTransform: []MetricIndexTransform{{Start: 1}}},
87 + },
88 + },
89 + },
90 + }
91 +
92 + cloned := profile.Clone()
93 + require.Equal(t, profile, cloned)
94 +
95 + cloned.Licensing[0].State.Symbol.Mapping.Items["1"] = "broken"
96 + cloned.Licensing[0].Signals.Expiry.Sentinel[0] = LicenseSentinelTimerPre1971
97 + cloned.Licensing[0].MetricTags[0].IndexTransform[0].Start = 2
98 +
99 + assert.Equal(t, "healthy", profile.Licensing[0].State.Symbol.Mapping.Items["1"])
100 + assert.Equal(t, []LicenseSentinelPolicy{LicenseSentinelTimerU32Max}, profile.Licensing[0].Signals.Expiry.Sentinel)
101 + assert.Equal(t, uint(1), profile.Licensing[0].MetricTags[0].IndexTransform[0].Start)
102 +}
103 +
104 +func TestValidateEnrichProfile_Licensing(t *testing.T) {
105 + tests := map[string]struct {
106 + profile ProfileDefinition
107 + wantErrContains []string
108 + }{
109 + "valid state and expiry": {
110 + profile: ProfileDefinition{
111 + Licensing: []LicensingConfig{
112 + {
113 + ID: "scalar-group",
114 + State: LicenseStateConfig{
115 + LicenseValueConfig: LicenseValueConfig{
116 + Symbol: SymbolConfig{OID: "1.2.3.0", Name: "licenseState"},
117 + },
118 + Policy: LicenseStatePolicyDefault,
119 + },
120 + Signals: LicenseSignalsConfig{
121 + Expiry: LicenseTimerSignalsConfig{
122 + LicenseValueConfig: LicenseValueConfig{
123 + Symbol: SymbolConfig{OID: "1.2.4.0", Name: "licenseExpiry"},
124 + Sentinel: []LicenseSentinelPolicy{LicenseSentinelTimerU32Max},
125 + },
126 + },
127 + },
128 + },
129 + },
130 + },
131 + },
132 + "invalid state policy": {
133 + profile: ProfileDefinition{
134 + Licensing: []LicensingConfig{
135 + {State: LicenseStateConfig{Policy: LicenseStatePolicy("filename_gate")}},
136 + },
137 + },
138 + wantErrContains: []string{`licensing[0].state.policy: invalid policy "filename_gate"`},
139 + },
140 + "invalid sentinel policy": {
141 + profile: ProfileDefinition{
142 + Licensing: []LicensingConfig{
143 + {
144 + Signals: LicenseSignalsConfig{
145 + Expiry: LicenseTimerSignalsConfig{
146 + LicenseValueConfig: LicenseValueConfig{
147 + Sentinel: []LicenseSentinelPolicy{LicenseSentinelPolicy("magic_zero")},
148 + },
149 + },
150 + },
151 + },
152 + },
153 + },
154 + wantErrContains: []string{`licensing[0].signals.expiry.sentinel[0]: invalid policy "magic_zero"`},
155 + },
156 + "invalid signal kind": {
157 + profile: ProfileDefinition{
158 + Licensing: []LicensingConfig{
159 + {
160 + Signals: LicenseSignalsConfig{
161 + Usage: LicenseUsageSignalsConfig{
162 + Used: LicenseValueConfig{Kind: LicenseSignalKind("usage_typo")},
163 + },
164 + },
165 + },
166 + },
167 + },
168 + wantErrContains: []string{`licensing[0].signals.usage.used.kind: invalid kind "usage_typo"`},
169 + },
170 + "forbids transform extraction match and underscore symbol names": {
171 + profile: ProfileDefinition{
172 + Licensing: []LicensingConfig{
173 + {
174 + State: LicenseStateConfig{
175 + LicenseValueConfig: LicenseValueConfig{
176 + Symbol: SymbolConfig{
177 + OID: "1.2.3.0",
178 + Name: "_license_row",
179 + ChartMeta: ChartMeta{Description: "chart-only"},
180 + MetricType: ProfileMetricTypeGauge,
181 + Transform: "{{ .Value }}",
182 + ExtractValue: `(\d+)`,
183 + MatchPattern: `(\d+)`,
184 + MatchValue: "$1",
185 + ScaleFactor: 0.1,
186 + ConstantValueOne: true,
187 + },
188 + },
189 + },
190 + },
191 + },
192 + },
193 + wantErrContains: []string{
194 + `licensing[0].state.symbol: name "_license_row" cannot be underscore-prefixed`,
195 + "licensing[0].state.symbol: chart_meta cannot be used in licensing rows",
196 + "licensing[0].state.symbol: metric_type cannot be used in licensing rows",
197 + "licensing[0].state.symbol: transform cannot be used in licensing rows",
198 + "licensing[0].state.symbol: extract_value cannot be used in licensing rows",
199 + "licensing[0].state.symbol: match_pattern cannot be used in licensing rows",
200 + "licensing[0].state.symbol: match_value cannot be used in licensing rows",
201 + "licensing[0].state.symbol: scale_factor cannot be used in licensing rows",
202 + "licensing[0].state.symbol: constant_value_one cannot be used in licensing rows",
203 + },
204 + },
205 + "forbids state policy without state source": {
206 + profile: ProfileDefinition{
207 + Licensing: []LicensingConfig{
208 + {
209 + State: LicenseStateConfig{Policy: LicenseStatePolicyDefault},
210 + Signals: LicenseSignalsConfig{
211 + Expiry: LicenseTimerSignalsConfig{
212 + LicenseValueConfig: LicenseValueConfig{Symbol: SymbolConfig{OID: "1.2.4.0", Name: "licenseExpiry"}},
213 + },
214 + },
215 + },
216 + },
217 + },
218 + wantErrContains: []string{"licensing[0].state.policy: policy requires state value source"},
219 + },
220 + "forbids scalar literal-only rows without explicit id": {
221 + profile: ProfileDefinition{
222 + Licensing: []LicensingConfig{
223 + {
224 + State: LicenseStateConfig{
225 + LicenseValueConfig: LicenseValueConfig{Value: "0"},
226 + },
227 + },
228 + },
229 + },
230 + wantErrContains: []string{"licensing[0]: scalar rows without a signal source OID require explicit id"},
231 + },
232 + "forbids timer timestamp and remaining together": {
233 + profile: ProfileDefinition{
234 + Licensing: []LicensingConfig{
235 + {
236 + ID: "scalar-group",
237 + Signals: LicenseSignalsConfig{
238 + Expiry: LicenseTimerSignalsConfig{
239 + Timestamp: LicenseValueConfig{Symbol: SymbolConfig{OID: "1.2.4.0", Name: "licenseExpiry"}},
240 + Remaining: LicenseValueConfig{Symbol: SymbolConfig{OID: "1.2.5.0", Name: "licenseExpiryRemaining"}},
241 + },
242 + },
243 + },
244 + },
245 + },
246 + wantErrContains: []string{"licensing[0].signals.expiry: timestamp and remaining cannot both be set"},
247 + },
248 + "forbids inline timer and remaining together": {
249 + profile: ProfileDefinition{
250 + Licensing: []LicensingConfig{
251 + {
252 + ID: "scalar-group",
253 + Signals: LicenseSignalsConfig{
254 + Expiry: LicenseTimerSignalsConfig{
255 + LicenseValueConfig: LicenseValueConfig{Symbol: SymbolConfig{OID: "1.2.4.0", Name: "licenseExpiry"}},
256 + Remaining: LicenseValueConfig{Symbol: SymbolConfig{OID: "1.2.5.0", Name: "licenseExpiryRemaining"}},
257 + },
258 + },
259 + },
260 + },
261 + },
262 + wantErrContains: []string{"licensing[0].signals.expiry: timestamp and remaining cannot both be set"},
263 + },
264 + "forbids legacy underscore top-level names": {
265 + profile: ProfileDefinition{
266 + Licensing: []LicensingConfig{
267 + {
268 + State: LicenseStateConfig{
269 + LicenseValueConfig: LicenseValueConfig{
270 + From: "1.2.3.0",
271 + Name: "_license_row",
272 + },
273 + },
274 + },
275 + },
276 + },
277 + wantErrContains: []string{`licensing[0].state.name: name "_license_row" cannot be underscore-prefixed`},
278 + },
279 + "forbids wrong field kind": {
280 + profile: ProfileDefinition{
281 + Licensing: []LicensingConfig{
282 + {
283 + State: LicenseStateConfig{
284 + LicenseValueConfig: LicenseValueConfig{
285 + Symbol: SymbolConfig{OID: "1.2.3.0", Name: "licenseState"},
286 + Kind: LicenseSignalUsageUsed,
287 + },
288 + },
289 + },
290 + },
291 + },
292 + wantErrContains: []string{`licensing[0].state.kind: expected "state_severity", got "usage_used"`},
293 + },
294 + "forbids unknown format": {
295 + profile: ProfileDefinition{
296 + Licensing: []LicensingConfig{
297 + {
298 + State: LicenseStateConfig{
299 + LicenseValueConfig: LicenseValueConfig{
300 + Symbol: SymbolConfig{OID: "1.2.3.0", Name: "licenseState", Format: "spreadsheet_date"},
301 + },
302 + },
303 + },
304 + },
305 + },
306 + wantErrContains: []string{`licensing[0].state.symbol: invalid format "spreadsheet_date"`},
307 + },
308 + "forbids descriptor-only rows": {
309 + profile: ProfileDefinition{
310 + Licensing: []LicensingConfig{
311 + {
312 + Identity: LicenseIdentityConfig{
313 + ID: LicenseValueConfig{Value: "license-a"},
314 + },
315 + Descriptors: LicenseDescriptorsConfig{
316 + Type: LicenseValueConfig{Value: "subscription"},
317 + },
318 + },
319 + },
320 + },
321 + wantErrContains: []string{"licensing[0]: must define state or at least one signal"},
322 + },
323 + "forbids ungrouped multi scalar signal rows": {
324 + profile: ProfileDefinition{
325 + Licensing: []LicensingConfig{
326 + {
327 + State: LicenseStateConfig{
328 + LicenseValueConfig: LicenseValueConfig{
329 + Symbol: SymbolConfig{OID: "1.2.3.0", Name: "licenseState"},
330 + },
331 + },
332 + Signals: LicenseSignalsConfig{
333 + Expiry: LicenseTimerSignalsConfig{
334 + LicenseValueConfig: LicenseValueConfig{
335 + Symbol: SymbolConfig{OID: "1.2.4.0", Name: "licenseExpiry"},
336 + },
337 + },
338 + },
339 + },
340 + },
341 + },
342 + wantErrContains: []string{"licensing[0]: scalar rows with multiple signal source OIDs require explicit id"},
343 + },
344 + "forbids scalar index lookups": {
345 + profile: ProfileDefinition{
346 + Licensing: []LicensingConfig{
347 + {
348 + State: LicenseStateConfig{
349 + LicenseValueConfig: LicenseValueConfig{Index: 1},
350 + },
351 + },
352 + },
353 + },
354 + wantErrContains: []string{"licensing[0].state.index: scalar licensing values do not support `index` lookups"},
355 + },
356 + "forbids scalar metric tag index lookups": {
357 + profile: ProfileDefinition{
358 + Licensing: []LicensingConfig{
359 + {
360 + State: LicenseStateConfig{
361 + LicenseValueConfig: LicenseValueConfig{Symbol: SymbolConfig{OID: "1.2.3.0", Name: "licenseState"}},
362 + },
363 + MetricTags: MetricTagConfigList{
364 + {Tag: "license_row", Index: 1},
365 + },
366 + },
367 + },
368 + },
369 + wantErrContains: []string{"licensing[0].metric_tags[0]: scalar metric_tags do not support `index` lookups"},
370 + },
371 + "forbids table from outside row table": {
372 + profile: ProfileDefinition{
373 + Licensing: []LicensingConfig{
374 + {
375 + Table: SymbolConfig{OID: "1.2.3", Name: "licenseTable"},
376 + State: LicenseStateConfig{
377 + LicenseValueConfig: LicenseValueConfig{From: "1.2.4.1"},
378 + },
379 + },
380 + },
381 + },
382 + wantErrContains: []string{`licensing[0].state.from: OID "1.2.4.1" is outside table "1.2.3"`},
383 + },
384 + "forbids duplicate signal kinds for same identity": {
385 + profile: ProfileDefinition{
386 + Licensing: []LicensingConfig{
387 + {
388 + OriginProfileID: "_vendor-licensing.yaml",
389 + ID: "smart",
390 + State: LicenseStateConfig{
391 + LicenseValueConfig: LicenseValueConfig{Symbol: SymbolConfig{OID: "1.2.3.0", Name: "licenseState"}},
392 + },
393 + },
394 + {
395 + OriginProfileID: "_vendor-licensing.yaml",
396 + ID: "smart",
397 + State: LicenseStateConfig{
398 + LicenseValueConfig: LicenseValueConfig{Symbol: SymbolConfig{OID: "1.2.4.0", Name: "licenseState2"}},
399 + },
400 + },
401 + },
402 + },
403 + wantErrContains: []string{`duplicate signal kind "state_severity" for structural identity "_vendor-licensing.yaml|scalar-group|smart"`},
404 + },
405 + "allows format and mapping": {
406 + profile: ProfileDefinition{
407 + Licensing: []LicensingConfig{
408 + {
409 + State: LicenseStateConfig{
410 + LicenseValueConfig: LicenseValueConfig{
411 + Symbol: SymbolConfig{
412 + OID: "1.2.3.0",
413 + Name: "licenseState",
414 + Format: "text_date",
415 + Mapping: NewExactMapping(map[string]string{"1": "healthy"}),
416 + },
417 + },
418 + },
419 + },
420 + },
421 + },
422 + },
423 + }
424 +
425 + for name, tc := range tests {
426 + t.Run(name, func(t *testing.T) {
427 + err := ValidateEnrichProfile(&tc.profile)
428 + if len(tc.wantErrContains) == 0 {
429 + require.NoError(t, err)
430 + return
431 + }
432 +
433 + require.Error(t, err)
434 + for _, want := range tc.wantErrContains {
435 + assert.Contains(t, err.Error(), want)
436 + }
437 + })
438 + }
439 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/profile_definition.go
+2
@@ -15,6 +15,7 @@ type ProfileDefinition struct {
15 SysobjectIDMetadata []SysobjectIDMetadataEntryConfig `yaml:"sysobjectid_metadata,omitempty"`
16 Metrics []MetricsConfig `yaml:"metrics,omitempty" json:"metrics,omitempty"`
17 Topology []TopologyConfig `yaml:"topology,omitempty" json:"topology,omitempty"`
18 + Licensing []LicensingConfig `yaml:"licensing,omitempty" json:"licensing,omitempty"`
19 MetricTags []GlobalMetricTagConfig `yaml:"metric_tags,omitempty" json:"metric_tags,omitempty"`
20 StaticTags []StaticMetricTagConfig `yaml:"static_tags,omitempty" json:"static_tags,omitempty"`
21
@@ -39,6 +40,7 @@ func (p *ProfileDefinition) Clone() *ProfileDefinition {
40 StaticTags: slices.Clone(p.StaticTags),
41 Metrics: cloneSlice(p.Metrics),
42 Topology: cloneSlice(p.Topology),
43 + Licensing: cloneSlice(p.Licensing),
44 VirtualMetrics: cloneSlice(p.VirtualMetrics),
45 }
46 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition/validation.go
+402 -1
@@ -70,6 +70,7 @@ const (
70 func ValidateEnrichProfile(p *ProfileDefinition) error {
71 normalizeMetrics(p.Metrics)
72 normalizeTopology(p.Topology)
73 + normalizeLicensing(p.Licensing)
74
75 errs := []error{
76 validateEnrichLegacySelector(p),
@@ -77,6 +78,7 @@ func ValidateEnrichProfile(p *ProfileDefinition) error {
78 validateEnrichSysobjectIDMetadata(p.SysobjectIDMetadata),
79 validateEnrichMetrics(p.Metrics),
80 validateEnrichTopology(p.Topology),
81 + validateEnrichLicensing(p.Licensing),
82 validateEnrichGlobalMetricTags(p.MetricTags),
83 validateEnrichVirtualMetrics(p.Metrics, p.Topology, p.VirtualMetrics),
84 }
@@ -99,6 +101,51 @@ func normalizeTopology(topology []TopologyConfig) {
101 }
102 }
103
104 +func normalizeLicensing(licensing []LicensingConfig) {
105 + for i := range licensing {
106 + normalizeLicenseValue(&licensing[i].Identity.ID)
107 + normalizeLicenseValue(&licensing[i].Identity.Name)
108 + normalizeLicenseValue(&licensing[i].Identity.Feature)
109 + normalizeLicenseValue(&licensing[i].Identity.Component)
110 + normalizeLicenseValue(&licensing[i].Descriptors.Type)
111 + normalizeLicenseValue(&licensing[i].Descriptors.Impact)
112 + normalizeLicenseValue(&licensing[i].Descriptors.Perpetual)
113 + normalizeLicenseValue(&licensing[i].Descriptors.Unlimited)
114 + normalizeLicenseValue(&licensing[i].State.LicenseValueConfig)
115 + normalizeLicenseSignals(&licensing[i].Signals)
116 + }
117 +}
118 +
119 +func normalizeLicenseSignals(signals *LicenseSignalsConfig) {
120 + normalizeLicenseTimerSignals(&signals.Expiry)
121 + normalizeLicenseTimerSignals(&signals.Authorization)
122 + normalizeLicenseTimerSignals(&signals.Certificate)
123 + normalizeLicenseTimerSignals(&signals.Grace)
124 + normalizeLicenseValue(&signals.Usage.Used)
125 + normalizeLicenseValue(&signals.Usage.Capacity)
126 + normalizeLicenseValue(&signals.Usage.Available)
127 + normalizeLicenseValue(&signals.Usage.Percent)
128 +}
129 +
130 +func normalizeLicenseTimerSignals(signals *LicenseTimerSignalsConfig) {
131 + normalizeLicenseValue(&signals.LicenseValueConfig)
132 + normalizeLicenseValue(&signals.Timestamp)
133 + normalizeLicenseValue(&signals.Remaining)
134 +}
135 +
136 +func normalizeLicenseValue(value *LicenseValueConfig) {
137 + if value.Symbol.Name == "" && value.Symbol.OID == "" && value.Name != "" && value.OID != "" {
138 + value.Symbol.Name = value.Name
139 + value.Symbol.OID = value.OID
140 + value.Name = ""
141 + value.OID = ""
142 + }
143 + if value.Symbol.Format == "" {
144 + value.Symbol.Format = value.Format
145 + value.Format = ""
146 + }
147 +}
148 +
149 func normalizeMetric(metric *MetricsConfig) {
150 if metric == nil {
151 return
@@ -361,6 +408,360 @@ func validateEnrichTopology(topology []TopologyConfig) error {
408 return errors.Join(errs...)
409 }
410
411 +func validateEnrichLicensing(licensing []LicensingConfig) error {
412 + var errs []error
413 + seenSignals := make(map[licenseSignalValidationKey]string)
414 +
415 + for i := range licensing {
416 + row := &licensing[i]
417 + isTable := row.Table.OID != ""
418 + errs = append(errs, validateEnrichLicenseRowShape(i, row))
419 + errs = append(errs, validateEnrichLicenseValue(fmt.Sprintf("licensing[%d].identity.id", i), &row.Identity.ID, isTable))
420 + errs = append(errs, validateEnrichLicenseValue(fmt.Sprintf("licensing[%d].identity.name", i), &row.Identity.Name, isTable))
421 + errs = append(errs, validateEnrichLicenseValue(fmt.Sprintf("licensing[%d].identity.feature", i), &row.Identity.Feature, isTable))
422 + errs = append(errs, validateEnrichLicenseValue(fmt.Sprintf("licensing[%d].identity.component", i), &row.Identity.Component, isTable))
423 + errs = append(errs, validateEnrichLicenseValue(fmt.Sprintf("licensing[%d].descriptors.type", i), &row.Descriptors.Type, isTable))
424 + errs = append(errs, validateEnrichLicenseValue(fmt.Sprintf("licensing[%d].descriptors.impact", i), &row.Descriptors.Impact, isTable))
425 + errs = append(errs, validateEnrichLicenseValue(fmt.Sprintf("licensing[%d].descriptors.perpetual", i), &row.Descriptors.Perpetual, isTable))
426 + errs = append(errs, validateEnrichLicenseValue(fmt.Sprintf("licensing[%d].descriptors.unlimited", i), &row.Descriptors.Unlimited, isTable))
427 + errs = append(errs, validateEnrichLicenseState(i, &row.State, isTable))
428 + errs = append(errs, validateEnrichLicenseSignals(i, &row.Signals, isTable))
429 + errs = append(errs, validateLicenseFromReferences(i, row))
430 + errs = append(errs, validateLicenseSignalDuplicates(i, row, seenSignals))
431 + for j := range row.MetricTags {
432 + errs = append(errs, validateEnrichMetricTag(&row.MetricTags[j]))
433 + if !isTable {
434 + metricTag := &row.MetricTags[j]
435 + if metricTag.Table != "" {
436 + errs = append(errs, fmt.Errorf("licensing[%d].metric_tags[%d]: scalar metric_tags do not support `table` lookups (tag=%q, table=%q)", i, j, metricTag.Tag, metricTag.Table))
437 + }
438 + if metricTag.Index != 0 {
439 + errs = append(errs, fmt.Errorf("licensing[%d].metric_tags[%d]: scalar metric_tags do not support `index` lookups (tag=%q, index=%d)", i, j, metricTag.Tag, metricTag.Index))
440 + }
441 + if len(metricTag.IndexTransform) > 0 {
442 + errs = append(errs, fmt.Errorf("licensing[%d].metric_tags[%d]: scalar metric_tags do not support `index_transform` (tag=%q)", i, j, metricTag.Tag))
443 + }
444 + if metricTag.Symbol.OID == "" {
445 + errs = append(errs, fmt.Errorf("licensing[%d].metric_tags[%d]: scalar metric_tags require `symbol.OID` (tag=%q)", i, j, metricTag.Tag))
446 + }
447 + }
448 + }
449 + }
450 +
451 + return errors.Join(errs...)
452 +}
453 +
454 +func validateEnrichLicenseRowShape(rowIdx int, row *LicensingConfig) error {
455 + var errs []error
456 +
457 + if row.Table.Name != "" && row.Table.OID == "" {
458 + errs = append(errs, fmt.Errorf("licensing[%d].table: table name %q requires table OID", rowIdx, row.Table.Name))
459 + }
460 + if row.Table.OID != "" && row.Table.Name == "" {
461 + errs = append(errs, fmt.Errorf("licensing[%d].table: table OID %q requires table name", rowIdx, row.Table.OID))
462 + }
463 + if !licenseRowHasSignalConfigs(*row) {
464 + errs = append(errs, fmt.Errorf("licensing[%d]: must define state or at least one signal", rowIdx))
465 + }
466 + if row.Table.OID == "" && row.ID == "" {
467 + sourceOIDs := collectLicenseSignalSourceOIDs(*row)
468 + switch {
469 + case len(sourceOIDs) == 0:
470 + errs = append(errs, fmt.Errorf("licensing[%d]: scalar rows without a signal source OID require explicit id", rowIdx))
471 + case len(sourceOIDs) > 1:
472 + errs = append(errs, fmt.Errorf("licensing[%d]: scalar rows with multiple signal source OIDs require explicit id", rowIdx))
473 + }
474 + }
475 +
476 + return errors.Join(errs...)
477 +}
478 +
479 +func validateEnrichLicenseState(rowIdx int, state *LicenseStateConfig, isTable bool) error {
480 + var errs []error
481 + if state.Policy != "" && !IsValidLicenseStatePolicy(state.Policy) {
482 + errs = append(errs, fmt.Errorf("licensing[%d].state.policy: invalid policy %q", rowIdx, state.Policy))
483 + }
484 + if state.Policy != "" && !state.LicenseValueConfig.IsSet() {
485 + errs = append(errs, fmt.Errorf("licensing[%d].state.policy: policy requires state value source", rowIdx))
486 + }
487 + errs = append(errs, validateEnrichLicenseValueKind(fmt.Sprintf("licensing[%d].state", rowIdx), &state.LicenseValueConfig, LicenseSignalStateSeverity, isTable))
488 + return errors.Join(errs...)
489 +}
490 +
491 +func validateEnrichLicenseSignals(rowIdx int, signals *LicenseSignalsConfig, isTable bool) error {
492 + var errs []error
493 + errs = append(errs, validateEnrichLicenseTimerSignals(rowIdx, "expiry", &signals.Expiry, LicenseSignalExpiryTimestamp, LicenseSignalExpiryRemaining, isTable))
494 + errs = append(errs, validateEnrichLicenseTimerSignals(rowIdx, "authorization", &signals.Authorization, LicenseSignalAuthorizationTimestamp, LicenseSignalAuthorizationRemaining, isTable))
495 + errs = append(errs, validateEnrichLicenseTimerSignals(rowIdx, "certificate", &signals.Certificate, LicenseSignalCertificateTimestamp, LicenseSignalCertificateRemaining, isTable))
496 + errs = append(errs, validateEnrichLicenseTimerSignals(rowIdx, "grace", &signals.Grace, LicenseSignalGraceTimestamp, LicenseSignalGraceRemaining, isTable))
497 + errs = append(errs, validateEnrichLicenseValueKind(fmt.Sprintf("licensing[%d].signals.usage.used", rowIdx), &signals.Usage.Used, LicenseSignalUsageUsed, isTable))
498 + errs = append(errs, validateEnrichLicenseValueKind(fmt.Sprintf("licensing[%d].signals.usage.capacity", rowIdx), &signals.Usage.Capacity, LicenseSignalUsageCapacity, isTable))
499 + errs = append(errs, validateEnrichLicenseValueKind(fmt.Sprintf("licensing[%d].signals.usage.available", rowIdx), &signals.Usage.Available, LicenseSignalUsageAvailable, isTable))
500 + errs = append(errs, validateEnrichLicenseValueKind(fmt.Sprintf("licensing[%d].signals.usage.percent", rowIdx), &signals.Usage.Percent, LicenseSignalUsagePercent, isTable))
501 + return errors.Join(errs...)
502 +}
503 +
504 +func validateEnrichLicenseTimerSignals(rowIdx int, name string, signals *LicenseTimerSignalsConfig, timestampKind, remainingKind LicenseSignalKind, isTable bool) error {
505 + var errs []error
506 + basePath := fmt.Sprintf("licensing[%d].signals.%s", rowIdx, name)
507 + if signals.LicenseValueConfig.IsSet() && signals.Timestamp.IsSet() {
508 + errs = append(errs, fmt.Errorf("%s: inline timestamp and timestamp cannot both be set", basePath))
509 + }
510 + if (signals.LicenseValueConfig.IsSet() || signals.Timestamp.IsSet()) && signals.Remaining.IsSet() {
511 + errs = append(errs, fmt.Errorf("%s: timestamp and remaining cannot both be set", basePath))
512 + }
513 + errs = append(errs, validateEnrichLicenseValueKind(basePath, &signals.LicenseValueConfig, timestampKind, isTable))
514 + errs = append(errs, validateEnrichLicenseValueKind(basePath+".timestamp", &signals.Timestamp, timestampKind, isTable))
515 + errs = append(errs, validateEnrichLicenseValueKind(basePath+".remaining", &signals.Remaining, remainingKind, isTable))
516 + return errors.Join(errs...)
517 +}
518 +
519 +func validateEnrichLicenseValueKind(path string, value *LicenseValueConfig, defaultKind LicenseSignalKind, isTable bool) error {
520 + if !value.IsSet() {
521 + return nil
522 + }
523 + if value.Kind == "" {
524 + value.Kind = defaultKind
525 + } else if value.Kind != defaultKind {
526 + if !IsValidLicenseSignalKind(value.Kind) {
527 + return fmt.Errorf("%s.kind: invalid kind %q", path, value.Kind)
528 + }
529 + return fmt.Errorf("%s.kind: expected %q, got %q", path, defaultKind, value.Kind)
530 + }
531 + return validateEnrichLicenseValue(path, value, isTable)
532 +}
533 +
534 +func validateEnrichLicenseValue(path string, value *LicenseValueConfig, isTable bool) error {
535 + var errs []error
536 + if !value.IsSet() {
537 + return nil
538 + }
539 +
540 + if value.Kind != "" && !IsValidLicenseSignalKind(value.Kind) {
541 + errs = append(errs, fmt.Errorf("%s.kind: invalid kind %q", path, value.Kind))
542 + }
543 + if !licenseValueHasSource(*value) {
544 + errs = append(errs, fmt.Errorf("%s: must define value, from, symbol.OID, OID, index, or index_transform", path))
545 + }
546 + for i, policy := range value.Sentinel {
547 + if !IsValidLicenseSentinelPolicy(policy) {
548 + errs = append(errs, fmt.Errorf("%s.sentinel[%d]: invalid policy %q", path, i, policy))
549 + }
550 + }
551 + errs = append(errs, validateMapping(value.Mapping, MetadataSymbol))
552 +
553 + if strings.HasPrefix(value.Name, "_") {
554 + errs = append(errs, fmt.Errorf("%s.name: name %q cannot be underscore-prefixed", path, value.Name))
555 + }
556 + if !isTable {
557 + if value.Index != 0 {
558 + errs = append(errs, fmt.Errorf("%s.index: scalar licensing values do not support `index` lookups", path))
559 + }
560 + if len(value.IndexTransform) > 0 {
561 + errs = append(errs, fmt.Errorf("%s.index_transform: scalar licensing values do not support `index_transform`", path))
562 + }
563 + }
564 + if value.Format != "" && !isValidLicenseValueFormat(value.Format) {
565 + errs = append(errs, fmt.Errorf("%s.format: invalid format %q", path, value.Format))
566 + }
567 + if value.Symbol.Format != "" && !isValidLicenseValueFormat(value.Symbol.Format) {
568 + errs = append(errs, fmt.Errorf("%s.symbol: invalid format %q", path, value.Symbol.Format))
569 + }
570 + if value.Symbol.OID != "" || value.Symbol.Name != "" {
571 + errs = append(errs, validateEnrichLicenseSymbol(path, &value.Symbol))
572 + }
573 +
574 + return errors.Join(errs...)
575 +}
576 +
577 +func validateEnrichLicenseSymbol(path string, symbol *SymbolConfig) error {
578 + var errs []error
579 +
580 + errs = append(errs, validateEnrichSymbol(symbol, MetadataSymbol))
581 + if strings.HasPrefix(symbol.Name, "_") {
582 + errs = append(errs, fmt.Errorf("%s.symbol: name %q cannot be underscore-prefixed", path, symbol.Name))
583 + }
584 + if symbol.ChartMeta != (ChartMeta{}) {
585 + errs = append(errs, fmt.Errorf("%s.symbol: chart_meta cannot be used in licensing rows", path))
586 + }
587 + if symbol.MetricType != "" {
588 + errs = append(errs, fmt.Errorf("%s.symbol: metric_type cannot be used in licensing rows", path))
589 + }
590 + if symbol.Transform != "" {
591 + errs = append(errs, fmt.Errorf("%s.symbol: transform cannot be used in licensing rows", path))
592 + }
593 + if symbol.ExtractValue != "" {
594 + errs = append(errs, fmt.Errorf("%s.symbol: extract_value cannot be used in licensing rows", path))
595 + }
596 + if symbol.MatchPattern != "" {
597 + errs = append(errs, fmt.Errorf("%s.symbol: match_pattern cannot be used in licensing rows", path))
598 + }
599 + if symbol.MatchValue != "" {
600 + errs = append(errs, fmt.Errorf("%s.symbol: match_value cannot be used in licensing rows", path))
601 + }
602 + if symbol.ScaleFactor != 0 {
603 + errs = append(errs, fmt.Errorf("%s.symbol: scale_factor cannot be used in licensing rows", path))
604 + }
605 + if symbol.ConstantValueOne {
606 + errs = append(errs, fmt.Errorf("%s.symbol: constant_value_one cannot be used in licensing rows", path))
607 + }
608 +
609 + return errors.Join(errs...)
610 +}
611 +
612 +var validLicenseValueFormats = map[string]struct{}{
613 + "hex": {},
614 + "ip_address": {},
615 + "mac_address": {},
616 + "snmp_dateandtime": {},
617 + "text_date": {},
618 +}
619 +
620 +func isValidLicenseValueFormat(format string) bool {
621 + _, ok := validLicenseValueFormats[format]
622 + return ok
623 +}
624 +
625 +type licenseSignalValidationKey struct {
626 + identity string
627 + kind LicenseSignalKind
628 +}
629 +
630 +func validateLicenseSignalDuplicates(rowIdx int, row *LicensingConfig, seen map[licenseSignalValidationKey]string) error {
631 + var errs []error
632 + identity := LicenseStructuralIdentity(*row)
633 + for _, sig := range collectLicenseSignalValues(*row) {
634 + if sig.kind == "" || !sig.value.IsSet() {
635 + continue
636 + }
637 + path := fmt.Sprintf("licensing[%d].%s", rowIdx, sig.path)
638 + key := licenseSignalValidationKey{identity: identity, kind: sig.kind}
639 + if prev, ok := seen[key]; ok {
640 + errs = append(errs, fmt.Errorf("%s: duplicate signal kind %q for structural identity %q (first seen at %s)", path, sig.kind, identity, prev))
641 + continue
642 + }
643 + seen[key] = path
644 + }
645 + return errors.Join(errs...)
646 +}
647 +
648 +func validateLicenseFromReferences(rowIdx int, row *LicensingConfig) error {
649 + if row.Table.OID == "" {
650 + return nil
651 + }
652 +
653 + var errs []error
654 + tableOID := TrimLicenseOID(row.Table.OID)
655 + for _, ref := range collectLicenseValueReferences(*row) {
656 + if ref.value.From == "" {
657 + continue
658 + }
659 + fromOID := TrimLicenseOID(ref.value.From)
660 + if !oidHasPrefix(fromOID, tableOID) {
661 + errs = append(errs, fmt.Errorf("licensing[%d].%s.from: OID %q is outside table %q", rowIdx, ref.path, ref.value.From, row.Table.OID))
662 + }
663 + }
664 + return errors.Join(errs...)
665 +}
666 +
667 +type licenseValueValidationRef struct {
668 + path string
669 + value LicenseValueConfig
670 +}
671 +
672 +type licenseSignalValueValidationRef struct {
673 + path string
674 + kind LicenseSignalKind
675 + value LicenseValueConfig
676 +}
677 +
678 +func collectLicenseSignalValues(row LicensingConfig) []licenseSignalValueValidationRef {
679 + var values []licenseSignalValueValidationRef
680 + add := func(path string, value LicenseValueConfig) {
681 + if value.IsSet() {
682 + values = append(values, licenseSignalValueValidationRef{path: path, kind: value.Kind, value: value})
683 + }
684 + }
685 + add("state", row.State.LicenseValueConfig)
686 + collectLicenseTimerSignalValues("signals.expiry", row.Signals.Expiry, add)
687 + collectLicenseTimerSignalValues("signals.authorization", row.Signals.Authorization, add)
688 + collectLicenseTimerSignalValues("signals.certificate", row.Signals.Certificate, add)
689 + collectLicenseTimerSignalValues("signals.grace", row.Signals.Grace, add)
690 + add("signals.usage.used", row.Signals.Usage.Used)
691 + add("signals.usage.capacity", row.Signals.Usage.Capacity)
692 + add("signals.usage.available", row.Signals.Usage.Available)
693 + add("signals.usage.percent", row.Signals.Usage.Percent)
694 + return values
695 +}
696 +
697 +func collectLicenseTimerSignalValues(path string, cfg LicenseTimerSignalsConfig, add func(string, LicenseValueConfig)) {
698 + add(path, cfg.LicenseValueConfig)
699 + add(path+".timestamp", cfg.Timestamp)
700 + add(path+".remaining", cfg.Remaining)
701 +}
702 +
703 +func collectLicenseValueReferences(row LicensingConfig) []licenseValueValidationRef {
704 + var values []licenseValueValidationRef
705 + add := func(path string, value LicenseValueConfig) {
706 + if value.IsSet() {
707 + values = append(values, licenseValueValidationRef{path: path, value: value})
708 + }
709 + }
710 + add("identity.id", row.Identity.ID)
711 + add("identity.name", row.Identity.Name)
712 + add("identity.feature", row.Identity.Feature)
713 + add("identity.component", row.Identity.Component)
714 + add("descriptors.type", row.Descriptors.Type)
715 + add("descriptors.impact", row.Descriptors.Impact)
716 + add("descriptors.perpetual", row.Descriptors.Perpetual)
717 + add("descriptors.unlimited", row.Descriptors.Unlimited)
718 + add("state", row.State.LicenseValueConfig)
719 + collectLicenseTimerSignalValues("signals.expiry", row.Signals.Expiry, add)
720 + collectLicenseTimerSignalValues("signals.authorization", row.Signals.Authorization, add)
721 + collectLicenseTimerSignalValues("signals.certificate", row.Signals.Certificate, add)
722 + collectLicenseTimerSignalValues("signals.grace", row.Signals.Grace, add)
723 + add("signals.usage.used", row.Signals.Usage.Used)
724 + add("signals.usage.capacity", row.Signals.Usage.Capacity)
725 + add("signals.usage.available", row.Signals.Usage.Available)
726 + add("signals.usage.percent", row.Signals.Usage.Percent)
727 + return values
728 +}
729 +
730 +func licenseRowHasSignalConfigs(row LicensingConfig) bool {
731 + if row.State.LicenseValueConfig.IsSet() {
732 + return true
733 + }
734 + for _, sig := range collectLicenseSignalValues(row) {
735 + if sig.value.IsSet() {
736 + return true
737 + }
738 + }
739 + return false
740 +}
741 +
742 +func collectLicenseSignalSourceOIDs(row LicensingConfig) map[string]struct{} {
743 + oids := make(map[string]struct{})
744 + for _, sig := range collectLicenseSignalValues(row) {
745 + if oid := LicenseValueSourceOID(sig.value); oid != "" {
746 + oids[TrimLicenseOID(oid)] = struct{}{}
747 + }
748 + }
749 + return oids
750 +}
751 +
752 +func licenseValueHasSource(value LicenseValueConfig) bool {
753 + return value.Value != "" ||
754 + value.From != "" ||
755 + value.Symbol.OID != "" ||
756 + value.OID != "" ||
757 + value.Index != 0 ||
758 + len(value.IndexTransform) > 0
759 +}
760 +
761 +func oidHasPrefix(oid, prefix string) bool {
762 + return oid == prefix || strings.HasPrefix(oid, prefix+".")
763 +}
764 +
765 func validateEnrichTopologySymbol(topologyIdx int, symbol *SymbolConfig, symbolContext SymbolContext) error {
766 var errs []error
767
@@ -407,7 +808,7 @@ func validateConsumers(path string, consumers ConsumerSet) error {
808 seen := make(map[ProfileConsumer]int)
809 for i, consumer := range consumers {
810 switch consumer {
410 - case ConsumerMetrics, ConsumerTopology:
811 + case ConsumerMetrics, ConsumerTopology, ConsumerLicensing:
812 default:
813 errs = append(errs, fmt.Errorf("%s[%d]: invalid consumer %q", path, i, consumer))
814 continue
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector.go
+30
@@ -204,6 +204,16 @@ func (c *Collector) collectProfile(ps *profileState) (*ddsnmp.ProfileMetrics, er
204 }
205 pm.TopologyMetrics = append(pm.TopologyMetrics, topologyMetrics...)
206
207 + now = time.Now()
208 + licenseRows, err := c.collectLicenseRows(ps.profile, &pm.Stats)
209 + if err != nil {
210 + c.log.Limit(licenseRowsFailedLogKey+ps.profile.SourceFile, 1, licenseRowsErrorLogEvery).
211 + Warningf("failed to collect licensing rows for profile %q: %v", ps.profile.SourceFile, err)
212 + }
213 + pm.LicenseRows = append(pm.LicenseRows, licenseRows...)
214 + pm.Stats.Metrics.Licensing += int64(len(licenseRows))
215 + pm.Stats.Timing.Licensing = time.Since(now)
216 +
217 for i := range pm.Metrics {
218 pm.Metrics[i].Profile = pm
219 }
@@ -221,6 +231,9 @@ func (c *Collector) updateProfileMetrics(pm *ddsnmp.ProfileMetrics) {
231 for i := range pm.TopologyMetrics {
232 sanitizeMetricMetadata(&pm.TopologyMetrics[i])
233 }
234 + for i := range pm.LicenseRows {
235 + sanitizeLicenseRow(&pm.LicenseRows[i])
236 + }
237 }
238
239 func sanitizeMetricMetadata(m *ddsnmp.Metric) {
@@ -238,6 +251,23 @@ func sanitizeMetricMetadata(m *ddsnmp.Metric) {
251 }
252 }
253
254 +func sanitizeLicenseRow(row *ddsnmp.LicenseRow) {
255 + row.ID = metricMetaReplacer.Replace(row.ID)
256 + row.Name = metricMetaReplacer.Replace(row.Name)
257 + row.Feature = metricMetaReplacer.Replace(row.Feature)
258 + row.Component = metricMetaReplacer.Replace(row.Component)
259 + row.Type = metricMetaReplacer.Replace(row.Type)
260 + row.Impact = metricMetaReplacer.Replace(row.Impact)
261 + row.State.Raw = metricMetaReplacer.Replace(row.State.Raw)
262 + for k, v := range row.Tags {
263 + if strings.HasPrefix(k, "rm:") {
264 + delete(row.Tags, k)
265 + continue
266 + }
267 + row.Tags[k] = metricMetaReplacer.Replace(v)
268 + }
269 +}
270 +
271 var metricMetaReplacer = strings.NewReplacer(
272 "'", "",
273 "\n", " ",
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_bluecoat_test.go new
+153
@@ -0,0 +1,153 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "strconv"
7 + "testing"
8 + "time"
9 +
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 + "github.com/netdata/netdata/go/plugins/logger"
16 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
17 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
18 +)
19 +
20 +func TestCollector_Collect_BlueCoatLicensingProfile(t *testing.T) {
21 + tests := map[string]struct {
22 + rows []blueCoatLicenseRow
23 + assertRows func(*testing.T, []ddsnmp.LicenseRow)
24 + }{
25 + "mixed subscription demo and perpetual rows": {
26 + rows: []blueCoatLicenseRow{
27 + {
28 + index: 1,
29 + application: "ProxySG",
30 + feature: "WebFilter",
31 + component: "policy-engine",
32 + expireType: 2,
33 + state: 1,
34 + expiry: time.Date(2030, time.November, 11, 0, 0, 0, 0, time.UTC),
35 + hasExpiryPDU: true,
36 + },
37 + {
38 + index: 2,
39 + application: "ProxySG",
40 + feature: "Demo Malware Analysis",
41 + component: "sandbox",
42 + expireType: 3,
43 + state: 2,
44 + expiry: time.Date(2026, time.April, 5, 12, 0, 0, 0, time.UTC),
45 + hasExpiryPDU: true,
46 + },
47 + {
48 + index: 3,
49 + application: "ProxySG",
50 + feature: "Base Platform",
51 + component: "proxy-core",
52 + expireType: 1,
53 + state: 1,
54 + },
55 + },
56 + assertRows: func(t *testing.T, rows []ddsnmp.LicenseRow) {
57 + require.Len(t, rows, 3)
58 + byID := licenseRowsByID(rows)
59 +
60 + subscription := byID["1"]
61 + require.NotEmpty(t, subscription)
62 + require.EqualValues(t, time.Date(2030, time.November, 11, 0, 0, 0, 0, time.UTC).Unix(), subscription.Expiry.Timestamp)
63 + require.EqualValues(t, 0, subscription.State.Severity)
64 + assert.Equal(t, "ProxySG", subscription.Name)
65 + assert.Equal(t, "WebFilter", subscription.Feature)
66 + assert.Equal(t, "policy-engine", subscription.Component)
67 + assert.Equal(t, "subscription", subscription.Type)
68 + assert.Equal(t, "1", subscription.State.Raw)
69 +
70 + expiredDemo := byID["2"]
71 + require.NotEmpty(t, expiredDemo)
72 + require.EqualValues(t, time.Date(2026, time.April, 5, 12, 0, 0, 0, time.UTC).Unix(), expiredDemo.Expiry.Timestamp)
73 + require.EqualValues(t, 2, expiredDemo.State.Severity)
74 + assert.Equal(t, "demo", expiredDemo.Type)
75 + assert.Equal(t, "2", expiredDemo.State.Raw)
76 +
77 + perpetual := byID["3"]
78 + require.NotEmpty(t, perpetual)
79 + require.EqualValues(t, 0, perpetual.State.Severity)
80 + assert.Equal(t, "perpetual", perpetual.Type)
81 + assert.True(t, perpetual.IsPerpetual)
82 + assert.Equal(t, "1", perpetual.State.Raw)
83 + assert.False(t, perpetual.Expiry.Has)
84 + },
85 + },
86 + }
87 +
88 + for name, tc := range tests {
89 + t.Run(name, func(t *testing.T) {
90 + ctrl, mockHandler := setupMockHandler(t)
91 + defer ctrl.Finish()
92 +
93 + expectBlueCoatLicensingWalk(mockHandler, tc.rows...)
94 + profile := mustLoadTypedLicensingProfile(t, "bluecoat-proxysg", func(row ddprofiledefinition.LicensingConfig) bool {
95 + return row.ID == "app_license_status"
96 + })
97 + require.Len(t, profile.Definition.Licensing, 1)
98 +
99 + collector := New(Config{
100 + SnmpClient: mockHandler,
101 + Profiles: []*ddsnmp.Profile{profile},
102 + Log: logger.New(),
103 + SysObjectID: "",
104 + })
105 +
106 + results, err := collector.Collect()
107 + require.NoError(t, err)
108 + require.Len(t, results, 1)
109 +
110 + pm := results[0]
111 + assert.Empty(t, pm.Metrics)
112 + assert.Empty(t, pm.HiddenMetrics)
113 + tc.assertRows(t, pm.LicenseRows)
114 + })
115 + }
116 +}
117 +
118 +type blueCoatLicenseRow struct {
119 + index int
120 + application string
121 + feature string
122 + component string
123 + expireType int
124 + state int
125 + expiry time.Time
126 + hasExpiryPDU bool
127 +}
128 +
129 +func expectBlueCoatLicensingWalk(mockHandler *snmpmock.MockHandler, rows ...blueCoatLicenseRow) {
130 + pdus := make([]gosnmp.SnmpPDU, 0, len(rows)*7)
131 +
132 + for _, row := range rows {
133 + idx := strconv.Itoa(row.index)
134 +
135 + pdus = append(pdus,
136 + createIntegerPDU("1.3.6.1.4.1.3417.2.16.1.1.1.1.1."+idx, row.index),
137 + createStringPDU("1.3.6.1.4.1.3417.2.16.1.1.1.1.2."+idx, row.application),
138 + createStringPDU("1.3.6.1.4.1.3417.2.16.1.1.1.1.3."+idx, row.feature),
139 + createStringPDU("1.3.6.1.4.1.3417.2.16.1.1.1.1.4."+idx, row.component),
140 + createIntegerPDU("1.3.6.1.4.1.3417.2.16.1.1.1.1.5."+idx, row.expireType),
141 + createIntegerPDU("1.3.6.1.4.1.3417.2.16.1.1.1.1.7."+idx, row.state),
142 + )
143 + if row.hasExpiryPDU {
144 + pdus = append(pdus, createDateAndTimePDU("1.3.6.1.4.1.3417.2.16.1.1.1.1.6."+idx, row.expiry))
145 + }
146 + }
147 +
148 + expectSNMPWalk(mockHandler,
149 + gosnmp.Version2c,
150 + "1.3.6.1.4.1.3417.2.16.1.1.1",
151 + pdus,
152 + )
153 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_cisco_smart_test.go new
+202
@@ -0,0 +1,202 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "strings"
7 + "testing"
8 +
9 + "github.com/gosnmp/gosnmp"
10 + snmpmock "github.com/gosnmp/gosnmp/mocks"
11 + "github.com/netdata/netdata/go/plugins/logger"
12 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
14 + "github.com/stretchr/testify/assert"
15 + "github.com/stretchr/testify/require"
16 +)
17 +
18 +func TestCollector_Collect_CiscoSmartLicensingProfile(t *testing.T) {
19 + tests := map[string]struct {
20 + setup func(*snmpmock.MockHandler)
21 + assertRows func(*testing.T, []ddsnmp.LicenseRow)
22 + }{
23 + "complete data": {
24 + setup: func(mockHandler *snmpmock.MockHandler) {
25 + expectSNMPGet(mockHandler,
26 + []string{
27 + "1.3.6.1.4.1.9.9.831.0.6.1.0",
28 + },
29 + []gosnmp.SnmpPDU{
30 + createIntegerPDU("1.3.6.1.4.1.9.9.831.0.6.1.0", 2),
31 + },
32 + )
33 + expectSNMPGet(mockHandler,
34 + []string{
35 + "1.3.6.1.4.1.9.9.831.0.7.1.0",
36 + "1.3.6.1.4.1.9.9.831.0.7.2.0",
37 + },
38 + []gosnmp.SnmpPDU{
39 + createGauge32PDU("1.3.6.1.4.1.9.9.831.0.7.1.0", 1775152800),
40 + createStringPDU("1.3.6.1.4.1.9.9.831.0.7.2.0", "Out of Compliance"),
41 + },
42 + )
43 + expectSNMPGet(mockHandler,
44 + []string{"1.3.6.1.4.1.9.9.831.0.6.3.0"},
45 + []gosnmp.SnmpPDU{createGauge32PDU("1.3.6.1.4.1.9.9.831.0.6.3.0", 1777831200)},
46 + )
47 + expectSNMPGet(mockHandler,
48 + []string{"1.3.6.1.4.1.9.9.831.0.7.4.2.0"},
49 + []gosnmp.SnmpPDU{createGauge32PDU("1.3.6.1.4.1.9.9.831.0.7.4.2.0", 1773943200)},
50 + )
51 + expectSNMPWalk(mockHandler,
52 + gosnmp.Version2c,
53 + "1.3.6.1.4.1.9.9.831.0.5.1",
54 + []gosnmp.SnmpPDU{
55 + createGauge32PDU("1.3.6.1.4.1.9.9.831.0.5.1.1.2.1", 42),
56 + createStringPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.3.1", "dna_advantage"),
57 + createStringPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.4.1", "17.12"),
58 + createIntegerPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.5.1", 8),
59 + createStringPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.6.1", "Cisco DNA Advantage entitlement"),
60 + createStringPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.7.1", "network-advantage"),
61 + },
62 + )
63 + },
64 + assertRows: func(t *testing.T, rows []ddsnmp.LicenseRow) {
65 + require.Len(t, rows, 5)
66 + byID := licenseRowsByID(rows)
67 +
68 + registration := byID["smart_registration"]
69 + require.True(t, registration.State.Has)
70 + assert.EqualValues(t, 0, registration.State.Severity)
71 + assert.Equal(t, "Smart Licensing registration", registration.Name)
72 +
73 + authorization := byID["smart_authorization"]
74 + require.True(t, authorization.State.Has)
75 + assert.EqualValues(t, 2, authorization.State.Severity)
76 + assert.Equal(t, "Out of Compliance", authorization.State.Raw)
77 + require.True(t, authorization.Authorization.Has)
78 + assert.EqualValues(t, 1775152800, authorization.Authorization.Timestamp)
79 +
80 + certificate := byID["smart_id_certificate"]
81 + require.True(t, certificate.Certificate.Has)
82 + assert.EqualValues(t, 1777831200, certificate.Certificate.Timestamp)
83 +
84 + evaluation := byID["smart_evaluation_period"]
85 + require.True(t, evaluation.Grace.Has)
86 + assert.EqualValues(t, 1773943200, evaluation.Grace.Timestamp)
87 +
88 + entitlement := byID["dna_advantage"]
89 + require.True(t, entitlement.Usage.HasUsed)
90 + assert.EqualValues(t, 42, entitlement.Usage.Used)
91 + require.True(t, entitlement.State.Has)
92 + assert.EqualValues(t, 2, entitlement.State.Severity)
93 + assert.Equal(t, "network-advantage", entitlement.Name)
94 + assert.Equal(t, "8", entitlement.State.Raw)
95 + },
96 + },
97 + "partial data": {
98 + setup: func(mockHandler *snmpmock.MockHandler) {
99 + expectSNMPGet(mockHandler,
100 + []string{
101 + "1.3.6.1.4.1.9.9.831.0.6.1.0",
102 + },
103 + []gosnmp.SnmpPDU{
104 + createIntegerPDU("1.3.6.1.4.1.9.9.831.0.6.1.0", 2),
105 + },
106 + )
107 + expectSNMPGet(mockHandler,
108 + []string{
109 + "1.3.6.1.4.1.9.9.831.0.7.1.0",
110 + "1.3.6.1.4.1.9.9.831.0.7.2.0",
111 + },
112 + []gosnmp.SnmpPDU{
113 + createStringPDU("1.3.6.1.4.1.9.9.831.0.7.2.0", "Authorized"),
114 + },
115 + )
116 + expectSNMPGet(mockHandler, []string{"1.3.6.1.4.1.9.9.831.0.6.3.0"}, nil)
117 + expectSNMPGet(mockHandler, []string{"1.3.6.1.4.1.9.9.831.0.7.4.2.0"}, nil)
118 + expectSNMPWalk(mockHandler,
119 + gosnmp.Version2c,
120 + "1.3.6.1.4.1.9.9.831.0.5.1",
121 + []gosnmp.SnmpPDU{
122 + createGauge32PDU("1.3.6.1.4.1.9.9.831.0.5.1.1.2.1", 7),
123 + createStringPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.3.1", "dna_essentials"),
124 + createStringPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.4.1", "17.9"),
125 + createIntegerPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.5.1", 3),
126 + createStringPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.6.1", "Cisco DNA Essentials entitlement"),
127 + createStringPDU("1.3.6.1.4.1.9.9.831.0.5.1.1.7.1", "network-essentials"),
128 + },
129 + )
130 + },
131 + assertRows: func(t *testing.T, rows []ddsnmp.LicenseRow) {
132 + require.Len(t, rows, 3)
133 + byID := licenseRowsByID(rows)
134 +
135 + require.True(t, byID["smart_registration"].State.Has)
136 + assert.EqualValues(t, 0, byID["smart_registration"].State.Severity)
137 + require.True(t, byID["smart_authorization"].State.Has)
138 + assert.EqualValues(t, 0, byID["smart_authorization"].State.Severity)
139 +
140 + entitlement := byID["dna_essentials"]
141 + require.True(t, entitlement.Usage.HasUsed)
142 + assert.EqualValues(t, 7, entitlement.Usage.Used)
143 + require.True(t, entitlement.State.Has)
144 + assert.EqualValues(t, 0, entitlement.State.Severity)
145 + assert.Equal(t, "3", entitlement.State.Raw)
146 +
147 + assert.NotContains(t, byID, "smart_id_certificate")
148 + assert.NotContains(t, byID, "smart_evaluation_period")
149 + },
150 + },
151 + }
152 +
153 + for name, tc := range tests {
154 + t.Run(name, func(t *testing.T) {
155 + ctrl, mockHandler := setupMockHandler(t)
156 + defer ctrl.Finish()
157 +
158 + tc.setup(mockHandler)
159 + profile := mustLoadCiscoSmartProfile(t)
160 + require.True(t, hasLicensingTable(profile, "1.3.6.1.4.1.9.9.831.0.5.1"))
161 + collector := New(Config{
162 + SnmpClient: mockHandler,
163 + Profiles: []*ddsnmp.Profile{profile},
164 + Log: logger.New(),
165 + SysObjectID: "",
166 + })
167 +
168 + results, err := collector.Collect()
169 + require.NoError(t, err)
170 + require.Len(t, results, 1)
171 +
172 + pm := results[0]
173 + assert.Empty(t, pm.Metrics)
174 + assert.Empty(t, pm.HiddenMetrics)
175 + tc.assertRows(t, pm.LicenseRows)
176 + })
177 + }
178 +}
179 +
180 +func mustLoadCiscoSmartProfile(t *testing.T) *ddsnmp.Profile {
181 + t.Helper()
182 +
183 + return mustLoadTypedLicensingProfile(t, "cisco", func(row ddprofiledefinition.LicensingConfig) bool {
184 + const prefix = "1.3.6.1.4.1.9.9.831."
185 +
186 + if row.MIB == "CISCO-SMART-LIC-MIB" {
187 + return true
188 + }
189 + for _, sig := range ddprofiledefinition.LicenseSignalValueRefs(row) {
190 + if oid := strings.TrimPrefix(ddprofiledefinition.LicenseValueSourceOID(sig.Value), "."); oid != "" && strings.HasPrefix(oid, prefix) {
191 + return true
192 + }
193 + }
194 + if oid := strings.TrimPrefix(ddprofiledefinition.LicenseValueSourceOID(row.State.LicenseValueConfig), "."); oid != "" && strings.HasPrefix(oid, prefix) {
195 + return true
196 + }
197 + if oid := strings.TrimPrefix(row.Table.OID, "."); oid != "" && strings.HasPrefix(oid, prefix) {
198 + return true
199 + }
200 + return false
201 + })
202 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_cisco_traditional_test.go new
+176
@@ -0,0 +1,176 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "strconv"
7 + "strings"
8 + "testing"
9 + "time"
10 +
11 + "github.com/gosnmp/gosnmp"
12 + snmpmock "github.com/gosnmp/gosnmp/mocks"
13 + "github.com/stretchr/testify/assert"
14 + "github.com/stretchr/testify/require"
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"
19 +)
20 +
21 +func TestCollector_Collect_CiscoTraditionalLicensingProfile(t *testing.T) {
22 + tests := map[string]struct {
23 + rows []ciscoTraditionalRow
24 + assertRows func(*testing.T, []ddsnmp.LicenseRow)
25 + }{
26 + "subscription and grace rows": {
27 + rows: []ciscoTraditionalRow{
28 + {
29 + entPhysical: 1,
30 + storeUsed: 1,
31 + index: 17,
32 + name: "SECURITYK9",
33 + version: "1.0",
34 + licenseType: 5,
35 + remaining: 0,
36 + capacity: 100,
37 + available: 15,
38 + impact: "Security subscription",
39 + status: 3,
40 + endDate: time.Date(2030, time.November, 11, 0, 0, 0, 0, time.UTC),
41 + hasEndDatePDU: true,
42 + },
43 + {
44 + entPhysical: 1,
45 + storeUsed: 2,
46 + index: 23,
47 + name: "APPXK9",
48 + version: "2.1",
49 + licenseType: 3,
50 + remaining: 7200,
51 + capacity: 10,
52 + available: 0,
53 + impact: "Session count exhausted",
54 + status: 6,
55 + },
56 + },
57 + assertRows: func(t *testing.T, rows []ddsnmp.LicenseRow) {
58 + require.Len(t, rows, 2)
59 + byName := licenseRowsByName(rows)
60 +
61 + subscription := byName["SECURITYK9"]
62 + require.NotEmpty(t, subscription.ID)
63 + assert.Equal(t, "1.1.17", subscription.ID)
64 + assert.Equal(t, "1.0", subscription.Feature)
65 + assert.Equal(t, "traditional_licensing", subscription.Component)
66 + assert.Equal(t, "paid_subscription", subscription.Type)
67 + assert.Equal(t, "Security subscription", subscription.Impact)
68 + require.True(t, subscription.Expiry.Has)
69 + assert.EqualValues(t, time.Date(2030, time.November, 11, 0, 0, 0, 0, time.UTC).Unix(), subscription.Expiry.Timestamp)
70 + require.True(t, subscription.Usage.HasCapacity)
71 + assert.EqualValues(t, 100, subscription.Usage.Capacity)
72 + require.True(t, subscription.Usage.HasAvailable)
73 + assert.EqualValues(t, 15, subscription.Usage.Available)
74 + require.True(t, subscription.State.Has)
75 + assert.EqualValues(t, 0, subscription.State.Severity)
76 + assert.Equal(t, "3", subscription.State.Raw)
77 +
78 + grace := byName["APPXK9"]
79 + require.NotEmpty(t, grace.ID)
80 + assert.Equal(t, "1.2.23", grace.ID)
81 + assert.Equal(t, "2.1", grace.Feature)
82 + assert.Equal(t, "grace_period", grace.Type)
83 + assert.Equal(t, "Session count exhausted", grace.Impact)
84 + require.True(t, grace.Usage.HasCapacity)
85 + assert.EqualValues(t, 10, grace.Usage.Capacity)
86 + require.True(t, grace.Usage.HasAvailable)
87 + assert.EqualValues(t, 0, grace.Usage.Available)
88 + require.True(t, grace.State.Has)
89 + assert.EqualValues(t, 2, grace.State.Severity)
90 + assert.Equal(t, "6", grace.State.Raw)
91 + assert.False(t, grace.Expiry.Has)
92 + },
93 + },
94 + }
95 +
96 + for name, tc := range tests {
97 + t.Run(name, func(t *testing.T) {
98 + ctrl, mockHandler := setupMockHandler(t)
99 + defer ctrl.Finish()
100 +
101 + expectCiscoTraditionalLicensingWalk(mockHandler, tc.rows...)
102 + profile := mustLoadTypedLicensingProfile(t, "cisco", func(row ddprofiledefinition.LicensingConfig) bool {
103 + return strings.TrimPrefix(row.Table.OID, ".") == "1.3.6.1.4.1.9.9.543.1.2.3.1"
104 + })
105 + require.Len(t, profile.Definition.Licensing, 1)
106 +
107 + collector := New(Config{
108 + SnmpClient: mockHandler,
109 + Profiles: []*ddsnmp.Profile{profile},
110 + Log: logger.New(),
111 + SysObjectID: "",
112 + })
113 +
114 + results, err := collector.Collect()
115 + require.NoError(t, err)
116 + require.Len(t, results, 1)
117 +
118 + pm := results[0]
119 + assert.Empty(t, pm.Metrics)
120 + assert.Empty(t, pm.HiddenMetrics)
121 + tc.assertRows(t, pm.LicenseRows)
122 + })
123 + }
124 +}
125 +
126 +type ciscoTraditionalRow struct {
127 + entPhysical int
128 + storeUsed int
129 + index int
130 + name string
131 + version string
132 + licenseType int
133 + remaining uint
134 + capacity uint
135 + available uint
136 + impact string
137 + status int
138 + endDate time.Time
139 + hasEndDatePDU bool
140 +}
141 +
142 +func expectCiscoTraditionalLicensingWalk(mockHandler *snmpmock.MockHandler, rows ...ciscoTraditionalRow) {
143 + pdus := make([]gosnmp.SnmpPDU, 0, len(rows)*10)
144 +
145 + for _, row := range rows {
146 + idx := strconv.Itoa(row.entPhysical) + "." + strconv.Itoa(row.storeUsed) + "." + strconv.Itoa(row.index)
147 +
148 + pdus = append(pdus,
149 + createStringPDU("1.3.6.1.4.1.9.9.543.1.2.3.1.3."+idx, row.name),
150 + createStringPDU("1.3.6.1.4.1.9.9.543.1.2.3.1.4."+idx, row.version),
151 + createIntegerPDU("1.3.6.1.4.1.9.9.543.1.2.3.1.5."+idx, row.licenseType),
152 + createGauge32PDU("1.3.6.1.4.1.9.9.543.1.2.3.1.8."+idx, row.remaining),
153 + createGauge32PDU("1.3.6.1.4.1.9.9.543.1.2.3.1.10."+idx, row.capacity),
154 + createGauge32PDU("1.3.6.1.4.1.9.9.543.1.2.3.1.11."+idx, row.available),
155 + createStringPDU("1.3.6.1.4.1.9.9.543.1.2.3.1.13."+idx, row.impact),
156 + createIntegerPDU("1.3.6.1.4.1.9.9.543.1.2.3.1.14."+idx, row.status),
157 + )
158 + if row.hasEndDatePDU {
159 + pdus = append(pdus, createDateAndTimePDU("1.3.6.1.4.1.9.9.543.1.2.3.1.16."+idx, row.endDate))
160 + }
161 + }
162 +
163 + expectSNMPWalk(mockHandler,
164 + gosnmp.Version2c,
165 + "1.3.6.1.4.1.9.9.543.1.2.3.1",
166 + pdus,
167 + )
168 +}
169 +
170 +func licenseRowsByName(rows []ddsnmp.LicenseRow) map[string]ddsnmp.LicenseRow {
171 + out := make(map[string]ddsnmp.LicenseRow, len(rows))
172 + for _, row := range rows {
173 + out[row.Name] = row
174 + }
175 + return out
176 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_license_fixtures_test.go new
+271
@@ -0,0 +1,271 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "strings"
7 + "testing"
8 + "time"
9 +
10 + "github.com/gosnmp/gosnmp"
11 + snmpmock "github.com/gosnmp/gosnmp/mocks"
12 + "github.com/netdata/netdata/go/plugins/logger"
13 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
14 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
15 + "github.com/stretchr/testify/assert"
16 + "github.com/stretchr/testify/require"
17 +)
18 +
19 +func TestCollector_Collect_LicensingProfileFixtures(t *testing.T) {
20 + tests := map[string]struct {
21 + profileName string
22 + setup func(*testing.T, *snmpmock.MockHandler)
23 + keep func(ddprofiledefinition.LicensingConfig) bool
24 + assertRows func(*testing.T, []ddsnmp.LicenseRow)
25 + }{
26 + "cisco traditional": {
27 + profileName: "cisco",
28 + setup: func(t *testing.T, mockHandler *snmpmock.MockHandler) {
29 + fixture := mustLoadSNMPFixture(t, "testdata/licensing/cisco-traditional.snmpwalk")
30 + expectSNMPWalkFromFixture(mockHandler, gosnmp.Version2c, fixture, "1.3.6.1.4.1.9.9.543.1.2.3.1")
31 + },
32 + keep: func(row ddprofiledefinition.LicensingConfig) bool {
33 + return strings.TrimPrefix(row.Table.OID, ".") == "1.3.6.1.4.1.9.9.543.1.2.3.1"
34 + },
35 + assertRows: assertCiscoTraditionalFixtureRows,
36 + },
37 + "checkpoint community sample": {
38 + profileName: "checkpoint",
39 + setup: func(t *testing.T, mockHandler *snmpmock.MockHandler) {
40 + fixture := mustLoadSNMPFixture(t, "testdata/licensing/checkpoint-community.snmpwalk")
41 + expectSNMPWalkFromFixture(mockHandler, gosnmp.Version2c, fixture, "1.3.6.1.4.1.2620.1.6.18.1")
42 + },
43 + keep: func(row ddprofiledefinition.LicensingConfig) bool {
44 + return strings.TrimPrefix(row.Table.OID, ".") == "1.3.6.1.4.1.2620.1.6.18.1"
45 + },
46 + assertRows: assertCheckPointCommunityFixtureRows,
47 + },
48 + "sophos scalar fixture": {
49 + profileName: "sophos-xgs-firewall",
50 + setup: expectSophosFixtureGets,
51 + keep: func(row ddprofiledefinition.LicensingConfig) bool {
52 + return row.MIB == "SFOS-FIREWALL-MIB" &&
53 + strings.HasPrefix(strings.TrimPrefix(ddprofiledefinition.LicenseValueSourceOID(row.State.LicenseValueConfig), "."), "1.3.6.1.4.1.2604.5.1.5.")
54 + },
55 + assertRows: assertSophosFixtureRows,
56 + },
57 + "mikrotik scalar fixture": {
58 + profileName: "mikrotik-router",
59 + setup: func(t *testing.T, mockHandler *snmpmock.MockHandler) {
60 + fixture := mustLoadSNMPFixture(t, "testdata/licensing/mikrotik-router.snmprec")
61 + mustExpectSNMPGetFromFixture(t, mockHandler, fixture, []string{
62 + "1.3.6.1.4.1.14988.1.1.4.2.0",
63 + })
64 + },
65 + keep: func(row ddprofiledefinition.LicensingConfig) bool {
66 + return row.ID == "routeros_upgrade"
67 + },
68 + assertRows: assertMikroTikFixtureRows,
69 + },
70 + "cisco smart entitlement fixture": {
71 + profileName: "cisco",
72 + setup: func(t *testing.T, mockHandler *snmpmock.MockHandler) {
73 + fixture := mustLoadSNMPFixture(t, "testdata/licensing/cisco-smart-iosxe-c9800.snmprec")
74 + expectSNMPWalkFromFixture(mockHandler, gosnmp.Version2c, fixture, "1.3.6.1.4.1.9.9.831.0.5.1")
75 + },
76 + keep: func(row ddprofiledefinition.LicensingConfig) bool {
77 + return strings.TrimPrefix(row.Table.OID, ".") == "1.3.6.1.4.1.9.9.831.0.5.1"
78 + },
79 + assertRows: assertCiscoSmartFixtureRows,
80 + },
81 + }
82 +
83 + for name, tc := range tests {
84 + t.Run(name, func(t *testing.T) {
85 + ctrl, mockHandler := setupMockHandler(t)
86 + defer ctrl.Finish()
87 +
88 + tc.setup(t, mockHandler)
89 + profile := mustLoadTypedLicensingProfile(t, tc.profileName, tc.keep)
90 + collector := New(Config{
91 + SnmpClient: mockHandler,
92 + Profiles: []*ddsnmp.Profile{profile},
93 + Log: logger.New(),
94 + SysObjectID: "",
95 + })
96 +
97 + results, err := collector.Collect()
98 + require.NoError(t, err)
99 + require.Len(t, results, 1)
100 +
101 + pm := results[0]
102 + assert.Empty(t, pm.Metrics)
103 + assert.Empty(t, pm.HiddenMetrics)
104 + tc.assertRows(t, pm.LicenseRows)
105 + })
106 + }
107 +}
108 +
109 +func expectSophosFixtureGets(t *testing.T, mockHandler *snmpmock.MockHandler) {
110 + t.Helper()
111 +
112 + fixture := mustLoadSNMPFixture(t, "testdata/licensing/sophos-xgs-firewall.snmprec")
113 + for _, pair := range [][]string{
114 + {"1.3.6.1.4.1.2604.5.1.5.1.1.0", "1.3.6.1.4.1.2604.5.1.5.1.2.0"},
115 + {"1.3.6.1.4.1.2604.5.1.5.2.1.0", "1.3.6.1.4.1.2604.5.1.5.2.2.0"},
116 + {"1.3.6.1.4.1.2604.5.1.5.3.1.0", "1.3.6.1.4.1.2604.5.1.5.3.2.0"},
117 + {"1.3.6.1.4.1.2604.5.1.5.4.1.0", "1.3.6.1.4.1.2604.5.1.5.4.2.0"},
118 + {"1.3.6.1.4.1.2604.5.1.5.5.1.0", "1.3.6.1.4.1.2604.5.1.5.5.2.0"},
119 + {"1.3.6.1.4.1.2604.5.1.5.6.1.0", "1.3.6.1.4.1.2604.5.1.5.6.2.0"},
120 + {"1.3.6.1.4.1.2604.5.1.5.7.1.0", "1.3.6.1.4.1.2604.5.1.5.7.2.0"},
121 + {"1.3.6.1.4.1.2604.5.1.5.8.1.0", "1.3.6.1.4.1.2604.5.1.5.8.2.0"},
122 + {"1.3.6.1.4.1.2604.5.1.5.9.1.0", "1.3.6.1.4.1.2604.5.1.5.9.2.0"},
123 + } {
124 + mustExpectSNMPGetFromFixture(t, mockHandler, fixture, pair)
125 + }
126 +}
127 +
128 +func assertCiscoTraditionalFixtureRows(t *testing.T, rows []ddsnmp.LicenseRow) {
129 + t.Helper()
130 +
131 + require.Len(t, rows, 2)
132 + byName := licenseRowsByName(rows)
133 +
134 + permanent := byName["ipbasek9"]
135 + require.NotEmpty(t, permanent.ID)
136 + assert.Equal(t, "1.1.1", permanent.ID)
137 + require.True(t, permanent.Usage.HasCapacity)
138 + assert.EqualValues(t, 4294967295, permanent.Usage.Capacity)
139 + require.True(t, permanent.Usage.HasAvailable)
140 + assert.EqualValues(t, 4294967295, permanent.Usage.Available)
141 + require.True(t, permanent.State.Has)
142 + assert.EqualValues(t, 0, permanent.State.Severity)
143 + assert.Equal(t, "permanent", permanent.Type)
144 + assert.True(t, permanent.IsPerpetual)
145 + assert.Equal(t, "3", permanent.State.Raw)
146 + assert.False(t, permanent.Expiry.Has)
147 +
148 + feature := byName["cme-srst"]
149 + require.NotEmpty(t, feature.ID)
150 + assert.Equal(t, "1.2.8", feature.ID)
151 + require.True(t, feature.Usage.HasCapacity)
152 + assert.EqualValues(t, 5000, feature.Usage.Capacity)
153 + require.True(t, feature.Usage.HasAvailable)
154 + assert.EqualValues(t, 5000, feature.Usage.Available)
155 + require.True(t, feature.State.Has)
156 + assert.EqualValues(t, 0, feature.State.Severity)
157 + assert.Equal(t, "eval_right_to_use", feature.Type)
158 + assert.Equal(t, "2", feature.State.Raw)
159 + assert.False(t, feature.Expiry.Has)
160 +}
161 +
162 +func assertCheckPointCommunityFixtureRows(t *testing.T, rows []ddsnmp.LicenseRow) {
163 + t.Helper()
164 +
165 + require.Len(t, rows, 5)
166 + byID := licenseRowsByID(rows)
167 +
168 + firewall := byID["0"]
169 + assert.Equal(t, "Firewall", firewall.Name)
170 + assert.Equal(t, "Not Entitled", firewall.State.Raw)
171 + assert.True(t, firewall.State.Has)
172 + assert.EqualValues(t, 2, firewall.State.Severity)
173 + assert.False(t, firewall.Expiry.Has)
174 +
175 + appCtrl := byID["4"]
176 + assert.Equal(t, "Application Ctrl", appCtrl.Name)
177 + assert.Equal(t, "Evaluation", appCtrl.State.Raw)
178 + assert.EqualValues(t, 1, appCtrl.State.Severity)
179 + assert.EqualValues(t, 1619246913, appCtrl.Expiry.Timestamp)
180 +
181 + urlFiltering := byID["5"]
182 + assert.Equal(t, "URL Filtering", urlFiltering.Name)
183 + assert.EqualValues(t, 1619246913, urlFiltering.Expiry.Timestamp)
184 +
185 + ips := byID["2"]
186 + assert.Equal(t, "IPS", ips.Name)
187 + assert.EqualValues(t, 1619246941, ips.Expiry.Timestamp)
188 +
189 + smartEvent := byID["1003"]
190 + assert.Equal(t, "SmartEvent", smartEvent.Name)
191 + assert.EqualValues(t, 1620542985, smartEvent.Expiry.Timestamp)
192 +}
193 +
194 +func assertSophosFixtureRows(t *testing.T, rows []ddsnmp.LicenseRow) {
195 + t.Helper()
196 +
197 + require.Len(t, rows, 9)
198 + byID := licenseRowsByID(rows)
199 + require.Len(t, byID, 9)
200 +
201 + expectations := map[string]struct {
202 + stateValue int64
203 + state string
204 + kind string
205 + }{
206 + "base_firewall": {stateValue: 2, state: "deactivated", kind: "subscription"},
207 + "network_protection": {stateValue: 0, state: "not_subscribed", kind: "subscription"},
208 + "web_protection": {stateValue: 1, state: "trial", kind: "subscription"},
209 + "mail_protection": {stateValue: 0, state: "subscribed", kind: "subscription"},
210 + "web_server_protection": {stateValue: 0, state: "subscribed", kind: "subscription"},
211 + "sandstorm": {stateValue: 0, state: "subscribed", kind: "subscription"},
212 + "enhanced_support": {stateValue: 0, state: "not_subscribed", kind: "support"},
213 + "enhanced_plus_support": {stateValue: 0, state: "not_subscribed", kind: "support"},
214 + "central_orchestration": {stateValue: 0, state: "none", kind: "subscription"},
215 + }
216 +
217 + for id, want := range expectations {
218 + row, ok := byID[id]
219 + require.Truef(t, ok, "missing license row for %s", id)
220 +
221 + require.Truef(t, row.State.Has, "missing state for %s", id)
222 + assert.EqualValues(t, want.stateValue, row.State.Severity, "unexpected severity for %s", id)
223 + assert.Equal(t, want.state, row.State.Raw, "unexpected raw state for %s", id)
224 + assert.Equal(t, want.kind, row.Type, "unexpected license type for %s", id)
225 +
226 + // This public Datadog capture anonymizes expiry strings, so it can prove
227 + // the real scalar OID layout and state mapping, but not real expiry parsing.
228 + assert.False(t, row.Expiry.Has, "anonymized fixture expiry should not parse for %s", id)
229 + }
230 +}
231 +
232 +func assertMikroTikFixtureRows(t *testing.T, rows []ddsnmp.LicenseRow) {
233 + t.Helper()
234 +
235 + require.Len(t, rows, 1)
236 +
237 + row := rows[0]
238 + assert.Equal(t, "routeros_upgrade", row.ID)
239 + assert.Equal(t, "RouterOS upgrade entitlement", row.Name)
240 + assert.Equal(t, "upgrade_entitlement", row.Type)
241 + assert.Equal(t, "routeros", row.Component)
242 + assert.True(t, row.Expiry.Has)
243 + assert.EqualValues(t, time.Date(2002, time.September, 21, 13, 53, 32, 300_000_000, time.UTC).Unix(), row.Expiry.Timestamp)
244 + assert.Equal(t, "1.3.6.1.4.1.14988.1.1.4.2.0", row.Expiry.SourceOID)
245 +}
246 +
247 +func assertCiscoSmartFixtureRows(t *testing.T, rows []ddsnmp.LicenseRow) {
248 + t.Helper()
249 +
250 + require.Len(t, rows, 2)
251 + byID := licenseRowsByID(rows)
252 +
253 + dna := byID["DNA_NWSTACK_E"]
254 + require.True(t, dna.Usage.HasUsed)
255 + assert.EqualValues(t, 55, dna.Usage.Used)
256 + require.True(t, dna.State.Has)
257 + assert.EqualValues(t, 0, dna.State.Severity)
258 + assert.Equal(t, "air-network-essentials", dna.Name)
259 + assert.Equal(t, "15", dna.State.Raw)
260 +
261 + airDNA := byID["AIR-DNA-E"]
262 + require.True(t, airDNA.Usage.HasUsed)
263 + assert.EqualValues(t, 55, airDNA.Usage.Used)
264 + require.True(t, airDNA.State.Has)
265 + assert.EqualValues(t, 0, airDNA.State.Severity)
266 + assert.Equal(t, "air-dna-essentials", airDNA.Name)
267 + assert.Equal(t, "15", airDNA.State.Raw)
268 +
269 + assert.NotContains(t, byID, "smart_registration")
270 + assert.NotContains(t, byID, "smart_authorization")
271 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_license_profiles_test.go new
+179
@@ -0,0 +1,179 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "strings"
7 + "testing"
8 + "time"
9 +
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 + "github.com/netdata/netdata/go/plugins/logger"
16 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
17 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
18 +)
19 +
20 +func TestCollector_Collect_LicensingProfiles(t *testing.T) {
21 + tests := map[string]struct {
22 + profileName string
23 + setup func(*snmpmock.MockHandler)
24 + keep func(ddprofiledefinition.LicensingConfig) bool
25 + assertRows func(*testing.T, []ddsnmp.LicenseRow)
26 + }{
27 + "checkpoint": {
28 + profileName: "checkpoint",
29 + setup: func(mockHandler *snmpmock.MockHandler) {
30 + expectSNMPWalk(mockHandler,
31 + gosnmp.Version2c,
32 + "1.3.6.1.4.1.2620.1.6.18.1",
33 + []gosnmp.SnmpPDU{
34 + createGauge32PDU("1.3.6.1.4.1.2620.1.6.18.1.1.1.17", 17),
35 + createGauge32PDU("1.3.6.1.4.1.2620.1.6.18.1.1.2.17", 17),
36 + createStringPDU("1.3.6.1.4.1.2620.1.6.18.1.1.4.17", "Application Control"),
37 + createStringPDU("1.3.6.1.4.1.2620.1.6.18.1.1.5.17", "about-to-expire"),
38 + createGauge32PDU("1.3.6.1.4.1.2620.1.6.18.1.1.6.17", 1775152800),
39 + createStringPDU("1.3.6.1.4.1.2620.1.6.18.1.1.7.17", "Threat prevention coverage"),
40 + createGauge32PDU("1.3.6.1.4.1.2620.1.6.18.1.1.9.17", 100),
41 + createGauge32PDU("1.3.6.1.4.1.2620.1.6.18.1.1.10.17", 85),
42 + },
43 + )
44 + },
45 + keep: func(row ddprofiledefinition.LicensingConfig) bool {
46 + return strings.TrimPrefix(row.Table.OID, ".") == "1.3.6.1.4.1.2620.1.6.18.1"
47 + },
48 + assertRows: func(t *testing.T, rows []ddsnmp.LicenseRow) {
49 + require.Len(t, rows, 1)
50 +
51 + row := rows[0]
52 + assert.Equal(t, "17", row.ID)
53 + assert.Equal(t, "Application Control", row.Name)
54 + assert.Equal(t, "about-to-expire", row.State.Raw)
55 + assert.True(t, row.State.Has)
56 + assert.EqualValues(t, 1, row.State.Severity)
57 + assert.EqualValues(t, 1775152800, row.Expiry.Timestamp)
58 + assert.True(t, row.Expiry.Has)
59 + assert.Equal(t, "Threat prevention coverage", row.Impact)
60 + assert.EqualValues(t, 100, row.Usage.Capacity)
61 + assert.True(t, row.Usage.HasCapacity)
62 + assert.EqualValues(t, 85, row.Usage.Used)
63 + assert.True(t, row.Usage.HasUsed)
64 + },
65 + },
66 + "fortigate": {
67 + profileName: "fortinet-fortigate",
68 + setup: expectFortiGateLicensingWalks,
69 + keep: func(row ddprofiledefinition.LicensingConfig) bool {
70 + return strings.HasPrefix(strings.TrimPrefix(row.Table.OID, "."), "1.3.6.1.4.1.12356.101.4.6.3.")
71 + },
72 + assertRows: func(t *testing.T, rows []ddsnmp.LicenseRow) {
73 + require.Len(t, rows, 3)
74 +
75 + byID := licenseRowsByID(rows)
76 + contract := byID["FortiCare Support"]
77 + require.NotEmpty(t, contract)
78 + require.EqualValues(t, 1920585600, contract.Expiry.Timestamp)
79 + assert.Equal(t, "FortiCare Support", contract.Name)
80 + assert.Equal(t, "contract", contract.Type)
81 + assert.Equal(t, "device", contract.Component)
82 +
83 + service := byID["FortiGuard Antivirus"]
84 + require.NotEmpty(t, service)
85 + require.EqualValues(t, 1753491600, service.Expiry.Timestamp)
86 + assert.Equal(t, "1.00000", service.Feature)
87 + assert.Equal(t, "service", service.Type)
88 + assert.Equal(t, "fortiguard", service.Component)
89 +
90 + accountContract := byID["FortiCare Premium"]
91 + require.NotEmpty(t, accountContract)
92 + require.EqualValues(t, 1920585600, accountContract.Expiry.Timestamp)
93 + assert.Equal(t, "account_contract", accountContract.Type)
94 + assert.Equal(t, "account", accountContract.Component)
95 + },
96 + },
97 + "mikrotik": {
98 + profileName: "mikrotik-router",
99 + setup: func(mockHandler *snmpmock.MockHandler) {
100 + expectSNMPGet(mockHandler,
101 + []string{
102 + "1.3.6.1.4.1.14988.1.1.4.2.0",
103 + },
104 + []gosnmp.SnmpPDU{
105 + createDateAndTimePDU("1.3.6.1.4.1.14988.1.1.4.2.0", time.Date(2030, time.January, 1, 0, 0, 0, 0, time.UTC)),
106 + },
107 + )
108 + },
109 + keep: func(row ddprofiledefinition.LicensingConfig) bool {
110 + return row.ID == "routeros_upgrade"
111 + },
112 + assertRows: func(t *testing.T, rows []ddsnmp.LicenseRow) {
113 + require.Len(t, rows, 1)
114 +
115 + row := rows[0]
116 + assert.Equal(t, "routeros_upgrade", row.ID)
117 + assert.Equal(t, "RouterOS upgrade entitlement", row.Name)
118 + assert.Equal(t, "upgrade_entitlement", row.Type)
119 + assert.Equal(t, "routeros", row.Component)
120 + assert.True(t, row.Expiry.Has)
121 + assert.EqualValues(t, time.Date(2030, time.January, 1, 0, 0, 0, 0, time.UTC).Unix(), row.Expiry.Timestamp)
122 + assert.Equal(t, "1.3.6.1.4.1.14988.1.1.4.2.0", row.Expiry.SourceOID)
123 + },
124 + },
125 + }
126 +
127 + for name, tc := range tests {
128 + t.Run(name, func(t *testing.T) {
129 + ctrl, mockHandler := setupMockHandler(t)
130 + defer ctrl.Finish()
131 +
132 + tc.setup(mockHandler)
133 + profile := mustLoadTypedLicensingProfile(t, tc.profileName, tc.keep)
134 + collector := New(Config{
135 + SnmpClient: mockHandler,
136 + Profiles: []*ddsnmp.Profile{profile},
137 + Log: logger.New(),
138 + SysObjectID: "",
139 + })
140 +
141 + results, err := collector.Collect()
142 + require.NoError(t, err)
143 + require.Len(t, results, 1)
144 +
145 + pm := results[0]
146 + assert.Empty(t, pm.Metrics)
147 + assert.Empty(t, pm.HiddenMetrics)
148 + tc.assertRows(t, pm.LicenseRows)
149 + })
150 + }
151 +}
152 +
153 +func expectFortiGateLicensingWalks(mockHandler *snmpmock.MockHandler) {
154 + expectSNMPWalk(mockHandler,
155 + gosnmp.Version2c,
156 + "1.3.6.1.4.1.12356.101.4.6.3.1.2",
157 + []gosnmp.SnmpPDU{
158 + createStringPDU("1.3.6.1.4.1.12356.101.4.6.3.1.2.1.1.1", "FortiCare Support"),
159 + createStringPDU("1.3.6.1.4.1.12356.101.4.6.3.1.2.1.2.1", "Mon 11 November 2030"),
160 + },
161 + )
162 + expectSNMPWalk(mockHandler,
163 + gosnmp.Version2c,
164 + "1.3.6.1.4.1.12356.101.4.6.3.2.2",
165 + []gosnmp.SnmpPDU{
166 + createStringPDU("1.3.6.1.4.1.12356.101.4.6.3.2.2.1.1.2", "FortiGuard Antivirus"),
167 + createStringPDU("1.3.6.1.4.1.12356.101.4.6.3.2.2.1.2.2", "Sat Jul 26 01:00:00 2025"),
168 + createStringPDU("1.3.6.1.4.1.12356.101.4.6.3.2.2.1.3.2", "1.00000"),
169 + },
170 + )
171 + expectSNMPWalk(mockHandler,
172 + gosnmp.Version2c,
173 + "1.3.6.1.4.1.12356.101.4.6.3.3.2",
174 + []gosnmp.SnmpPDU{
175 + createStringPDU("1.3.6.1.4.1.12356.101.4.6.3.3.2.1.1.7", "FortiCare Premium"),
176 + createStringPDU("1.3.6.1.4.1.12356.101.4.6.3.3.2.1.2.7", "Mon 11 November 2030"),
177 + },
178 + )
179 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_licensing.go new
+946
@@ -0,0 +1,946 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "errors"
7 + "fmt"
8 + "maps"
9 + "slices"
10 + "strconv"
11 + "strings"
12 + "time"
13 +
14 + "github.com/gosnmp/gosnmp"
15 +
16 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
17 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
18 +)
19 +
20 +const (
21 + licenseRowsPartialErrorLogKey = "snmp-licensing-rows-partial-error:"
22 + licenseRowsFailedLogKey = "snmp-licensing-rows-failed:"
23 + licenseRowsErrorLogEvery = time.Hour
24 +)
25 +
26 +type licenseValueContext struct {
27 + rowIndex string
28 + rowPDUs map[string]gosnmp.SnmpPDU
29 + pdus map[string]gosnmp.SnmpPDU
30 +}
31 +
32 +func (c *Collector) collectLicenseRows(prof *ddsnmp.Profile, stats *ddsnmp.CollectionStats) ([]ddsnmp.LicenseRow, error) {
33 + if prof.Definition == nil || len(prof.Definition.Licensing) == 0 {
34 + return nil, nil
35 + }
36 +
37 + var rows []ddsnmp.LicenseRow
38 + var errs []error
39 +
40 + scalarRows, err := c.collectScalarLicenseRows(prof.Definition.Licensing, stats)
41 + if err != nil {
42 + errs = append(errs, err)
43 + }
44 + rows = append(rows, scalarRows...)
45 +
46 + tableRows, err := c.collectTableLicenseRows(prof.Definition.Licensing, stats)
47 + if err != nil {
48 + errs = append(errs, err)
49 + }
50 + rows = append(rows, tableRows...)
51 +
52 + if len(rows) == 0 && len(errs) > 0 {
53 + return nil, errors.Join(errs...)
54 + }
55 + if len(errs) > 0 {
56 + c.log.Limit(licenseRowsPartialErrorLogKey+prof.SourceFile, 1, licenseRowsErrorLogEvery).
57 + Warningf("collecting licensing rows for profile %q partially failed: %v", prof.SourceFile, errors.Join(errs...))
58 + }
59 +
60 + return rows, nil
61 +}
62 +
63 +func (c *Collector) collectScalarLicenseRows(configs []ddprofiledefinition.LicensingConfig, stats *ddsnmp.CollectionStats) ([]ddsnmp.LicenseRow, error) {
64 + var rows []ddsnmp.LicenseRow
65 + var errs []error
66 +
67 + for _, cfg := range configs {
68 + if cfg.Table.OID != "" {
69 + continue
70 + }
71 +
72 + oids, missingOIDs := c.licensingScalarOIDs(cfg)
73 + if len(missingOIDs) > 0 {
74 + c.log.Debugf("licensing scalar row %q missing OIDs: %v", licensingConfigDisplayName(cfg), missingOIDs)
75 + stats.Errors.MissingOIDs += int64(len(missingOIDs))
76 + }
77 +
78 + var pdus map[string]gosnmp.SnmpPDU
79 + var err error
80 + if len(oids) > 0 {
81 + pdus, err = c.scalarCollector.getScalarValues(oids, stats)
82 + if err != nil {
83 + stats.Errors.SNMP++
84 + errs = append(errs, fmt.Errorf("licensing scalar row %q: %w", licensingConfigDisplayName(cfg), err))
85 + continue
86 + }
87 + }
88 +
89 + row, ok, err := c.buildScalarLicenseRow(cfg, pdus)
90 + if err != nil {
91 + stats.Errors.Processing.Licensing++
92 + errs = append(errs, fmt.Errorf("licensing scalar row %q: %w", licensingConfigDisplayName(cfg), err))
93 + continue
94 + }
95 + if ok {
96 + rows = append(rows, row)
97 + }
98 + }
99 +
100 + if len(rows) == 0 && len(errs) > 0 {
101 + return nil, errors.Join(errs...)
102 + }
103 + return rows, nil
104 +}
105 +
106 +func (c *Collector) collectTableLicenseRows(configs []ddprofiledefinition.LicensingConfig, stats *ddsnmp.CollectionStats) ([]ddsnmp.LicenseRow, error) {
107 + var rows []ddsnmp.LicenseRow
108 + var errs []error
109 + walkedData := make(map[string]map[string]gosnmp.SnmpPDU)
110 + tableNameToOID := licensingTableNameToOID(configs)
111 +
112 + for _, cfg := range configs {
113 + if cfg.Table.OID == "" {
114 + continue
115 + }
116 + metricsCfg := licensingConfigAsMetricsConfig(cfg)
117 + cachedRows, ok, err := c.collectTableLicenseRowsFromCache(cfg, metricsCfg, stats)
118 + if err != nil {
119 + c.log.Debugf("Cached licensing collection failed for table %s: %v", cfg.Table.Name, err)
120 + }
121 + if ok {
122 + stats.TableCache.Hits++
123 + stats.SNMP.TablesCached++
124 + rows = append(rows, cachedRows...)
125 + continue
126 + }
127 + stats.TableCache.Misses++
128 +
129 + tableOID := trimOID(cfg.Table.OID)
130 + pdus := walkedData[tableOID]
131 + if pdus == nil {
132 + var err error
133 + pdus, err = c.tableCollector.snmpWalk(cfg.Table.OID, stats)
134 + if err != nil {
135 + stats.Errors.SNMP++
136 + errs = append(errs, fmt.Errorf("licensing table %q: %w", licensingConfigDisplayName(cfg), err))
137 + continue
138 + }
139 + if len(pdus) > 0 {
140 + stats.SNMP.TablesWalked++
141 + walkedData[tableOID] = pdus
142 + }
143 + }
144 + if len(pdus) == 0 {
145 + continue
146 + }
147 +
148 + if err := c.walkLicenseTableDependencies(metricsCfg, tableNameToOID, walkedData, stats); err != nil {
149 + errs = append(errs, fmt.Errorf("licensing table %q dependencies: %w", licensingConfigDisplayName(cfg), err))
150 + continue
151 + }
152 +
153 + ctx := &tableProcessingContext{
154 + config: metricsCfg,
155 + pdus: pdus,
156 + walkedData: walkedData,
157 + tableNameToOID: tableNameToOID,
158 + }
159 + ctx.columnOIDs = buildColumnOIDs(metricsCfg)
160 + ctx.orderedTags = buildOrderedTags(metricsCfg)
161 + ctx.rows, ctx.oidCache, ctx.tagCache = c.tableCollector.organizePDUsByRow(ctx)
162 +
163 + for rowIndex, rowPDUs := range ctx.rows {
164 + row, ok, err := c.buildTableLicenseRow(cfg, rowIndex, rowPDUs, ctx)
165 + if err != nil {
166 + stats.Errors.Processing.Licensing++
167 + errs = append(errs, fmt.Errorf("licensing table %q row %q: %w", licensingConfigDisplayName(cfg), rowIndex, err))
168 + continue
169 + }
170 + if ok {
171 + rows = append(rows, row)
172 + }
173 + }
174 +
175 + deps := extractTableDependencies(metricsCfg, ctx.tableNameToOID)
176 + c.tableCollector.tableCache.cacheData(metricsCfg, ctx.oidCache, ctx.tagCache, deps)
177 + }
178 +
179 + if len(rows) == 0 && len(errs) > 0 {
180 + return nil, errors.Join(errs...)
181 + }
182 + return rows, nil
183 +}
184 +
185 +func (c *Collector) walkLicenseTableDependencies(
186 + cfg ddprofiledefinition.MetricsConfig,
187 + tableNameToOID map[string]string,
188 + walkedData map[string]map[string]gosnmp.SnmpPDU,
189 + stats *ddsnmp.CollectionStats,
190 +) error {
191 + var errs []error
192 + for _, depOID := range extractTableDependencies(cfg, tableNameToOID) {
193 + depOID = trimOID(depOID)
194 + if walkedData[depOID] != nil {
195 + continue
196 + }
197 + pdus, err := c.tableCollector.snmpWalk(depOID, stats)
198 + if err != nil {
199 + stats.Errors.SNMP++
200 + errs = append(errs, fmt.Errorf("table OID %q: %w", depOID, err))
201 + continue
202 + }
203 + if len(pdus) > 0 {
204 + stats.SNMP.TablesWalked++
205 + walkedData[depOID] = pdus
206 + }
207 + }
208 + return errors.Join(errs...)
209 +}
210 +
211 +func (c *Collector) collectTableLicenseRowsFromCache(cfg ddprofiledefinition.LicensingConfig, metricsCfg ddprofiledefinition.MetricsConfig, stats *ddsnmp.CollectionStats) ([]ddsnmp.LicenseRow, bool, error) {
212 + cachedOIDs, cachedTags, ok := c.tableCollector.tableCache.getCachedData(metricsCfg)
213 + if !ok {
214 + return nil, false, nil
215 + }
216 +
217 + columnOIDs := buildColumnOIDs(metricsCfg)
218 + var oidsToGet []string
219 + for _, columns := range cachedOIDs {
220 + for columnOID, fullOID := range columns {
221 + if _, ok := columnOIDs[columnOID]; ok {
222 + oidsToGet = append(oidsToGet, fullOID)
223 + }
224 + }
225 + }
226 + if len(oidsToGet) == 0 {
227 + return nil, true, nil
228 + }
229 +
230 + pdus, err := c.tableCollector.snmpGet(oidsToGet, stats)
231 + if err != nil {
232 + return nil, false, err
233 + }
234 + if len(pdus) < len(oidsToGet)/2 {
235 + return nil, false, fmt.Errorf("table structure may have changed, got %d/%d PDUs", len(pdus), len(oidsToGet))
236 + }
237 +
238 + var rows []ddsnmp.LicenseRow
239 + for rowIndex, columns := range cachedOIDs {
240 + rowPDUs := make(map[string]gosnmp.SnmpPDU)
241 + for columnOID, fullOID := range columns {
242 + pdu, ok := pdus[trimOID(fullOID)]
243 + if ok {
244 + rowPDUs[columnOID] = pdu
245 + }
246 + }
247 + rowTags := maps.Clone(cachedTags[rowIndex])
248 + row, ok, err := c.buildTableLicenseRowWithTags(cfg, rowIndex, rowPDUs, rowTags)
249 + if err != nil {
250 + return nil, false, fmt.Errorf("row %q: %w", rowIndex, err)
251 + }
252 + if ok {
253 + rows = append(rows, row)
254 + }
255 + }
256 + return rows, true, nil
257 +}
258 +
259 +func (c *Collector) buildScalarLicenseRow(cfg ddprofiledefinition.LicensingConfig, pdus map[string]gosnmp.SnmpPDU) (ddsnmp.LicenseRow, bool, error) {
260 + rowKey := scalarLicenseRowKey(cfg)
261 + row := ddsnmp.LicenseRow{
262 + OriginProfileID: cfg.OriginProfileID,
263 + RowKey: rowKey,
264 + StructuralID: licenseScalarStructuralID(cfg.OriginProfileID, rowKey),
265 + Tags: parseStaticTags(cfg.StaticTags),
266 + }
267 +
268 + licenseCtx := licenseValueContext{pdus: pdus}
269 + if err := c.populateLicenseRow(&row, cfg, licenseCtx); err != nil {
270 + return ddsnmp.LicenseRow{}, false, err
271 + }
272 + if row.ID == "" {
273 + row.ID = cfg.ID
274 + }
275 +
276 + if len(cfg.MetricTags) > 0 {
277 + tags := make(map[string]string)
278 + ta := tagAdder{tags: tags}
279 + for _, tagCfg := range cfg.MetricTags {
280 + if tagCfg.Symbol.OID == "" {
281 + continue
282 + }
283 + if err := c.scalarCollector.tagProc.processTag(tagCfg, pdus, ta); err != nil {
284 + c.log.Debugf("Error processing scalar licensing tag %s: %v", metricTagDisplayName(tagCfg), err)
285 + }
286 + }
287 + mergeStringMaps(row.Tags, tags)
288 + }
289 +
290 + if !licenseRowHasSignals(row) {
291 + return ddsnmp.LicenseRow{}, false, nil
292 + }
293 +
294 + return row, true, nil
295 +}
296 +
297 +func (c *Collector) buildTableLicenseRow(cfg ddprofiledefinition.LicensingConfig, rowIndex string, rowPDUs map[string]gosnmp.SnmpPDU, ctx *tableProcessingContext) (ddsnmp.LicenseRow, bool, error) {
298 + rowTags := make(map[string]string)
299 + rowData := &tableRowData{
300 + index: rowIndex,
301 + pdus: rowPDUs,
302 + tags: rowTags,
303 + staticTags: parseStaticTags(cfg.StaticTags),
304 + tableName: cfg.Table.Name,
305 + }
306 + crossTableCtx := &crossTableContext{
307 + walkedData: ctx.walkedData,
308 + tableNameToOID: ctx.tableNameToOID,
309 + lookupIndexCache: make(map[crossTableLookupKey]string),
310 + rowTags: rowData.tags,
311 + }
312 + rowCtx := &tableRowProcessingContext{
313 + config: ctx.config,
314 + columnOIDs: ctx.columnOIDs,
315 + crossTableCtx: crossTableCtx,
316 + orderedTags: ctx.orderedTags,
317 + }
318 + if err := c.tableCollector.rowProcessor.processRowTags(rowData, rowCtx); err != nil {
319 + c.log.Debugf("Error processing licensing row tags for %s: %v", rowIndex, err)
320 + }
321 + maps.Copy(ctx.tagCache[rowIndex], rowData.tags)
322 +
323 + return c.buildTableLicenseRowWithTags(cfg, rowIndex, rowPDUs, rowData.tags)
324 +}
325 +
326 +func (c *Collector) buildTableLicenseRowWithTags(cfg ddprofiledefinition.LicensingConfig, rowIndex string, rowPDUs map[string]gosnmp.SnmpPDU, rowTags map[string]string) (ddsnmp.LicenseRow, bool, error) {
327 + rowKey := rowIndex
328 + row := ddsnmp.LicenseRow{
329 + OriginProfileID: cfg.OriginProfileID,
330 + TableOID: trimOID(cfg.Table.OID),
331 + Table: cfg.Table.Name,
332 + RowKey: rowKey,
333 + StructuralID: licenseTableStructuralID(cfg.OriginProfileID, trimOID(cfg.Table.OID), rowKey),
334 + Tags: parseStaticTags(cfg.StaticTags),
335 + }
336 + mergeStringMaps(row.Tags, rowTags)
337 +
338 + licenseCtx := licenseValueContext{rowIndex: rowIndex, rowPDUs: rowPDUs}
339 + if err := c.populateLicenseRow(&row, cfg, licenseCtx); err != nil {
340 + return ddsnmp.LicenseRow{}, false, err
341 + }
342 + if row.ID == "" {
343 + row.ID = cfg.ID
344 + }
345 +
346 + if !licenseRowHasSignals(row) {
347 + return ddsnmp.LicenseRow{}, false, nil
348 + }
349 +
350 + return row, true, nil
351 +}
352 +
353 +func (c *Collector) populateLicenseRow(row *ddsnmp.LicenseRow, cfg ddprofiledefinition.LicensingConfig, ctx licenseValueContext) error {
354 + var err error
355 +
356 + if row.ID, err = c.licenseTextValue(cfg.Identity.ID, ctx); err != nil {
357 + return fmt.Errorf("identity.id: %w", err)
358 + }
359 + if row.Name, err = c.licenseTextValue(cfg.Identity.Name, ctx); err != nil {
360 + return fmt.Errorf("identity.name: %w", err)
361 + }
362 + if row.Feature, err = c.licenseTextValue(cfg.Identity.Feature, ctx); err != nil {
363 + return fmt.Errorf("identity.feature: %w", err)
364 + }
365 + if row.Component, err = c.licenseTextValue(cfg.Identity.Component, ctx); err != nil {
366 + return fmt.Errorf("identity.component: %w", err)
367 + }
368 + if row.Type, err = c.licenseTextValue(cfg.Descriptors.Type, ctx); err != nil {
369 + return fmt.Errorf("descriptors.type: %w", err)
370 + }
371 + if row.Impact, err = c.licenseTextValue(cfg.Descriptors.Impact, ctx); err != nil {
372 + return fmt.Errorf("descriptors.impact: %w", err)
373 + }
374 + if row.IsPerpetual, err = c.licenseBoolValue(cfg.Descriptors.Perpetual, ctx); err != nil {
375 + return fmt.Errorf("descriptors.perpetual: %w", err)
376 + }
377 + if row.IsUnlimited, err = c.licenseBoolValue(cfg.Descriptors.Unlimited, ctx); err != nil {
378 + return fmt.Errorf("descriptors.unlimited: %w", err)
379 + }
380 +
381 + if err := c.populateLicenseState(row, cfg.State, ctx); err != nil {
382 + return fmt.Errorf("state: %w", err)
383 + }
384 + if err := c.populateLicenseTimer(&row.Expiry, cfg.Signals.Expiry, ctx, "expiry"); err != nil {
385 + return err
386 + }
387 + if err := c.populateLicenseTimer(&row.Authorization, cfg.Signals.Authorization, ctx, "authorization"); err != nil {
388 + return err
389 + }
390 + if err := c.populateLicenseTimer(&row.Certificate, cfg.Signals.Certificate, ctx, "certificate"); err != nil {
391 + return err
392 + }
393 + if err := c.populateLicenseTimer(&row.Grace, cfg.Signals.Grace, ctx, "grace"); err != nil {
394 + return err
395 + }
396 + if err := c.populateLicenseUsage(&row.Usage, cfg.Signals.Usage, ctx); err != nil {
397 + return err
398 + }
399 +
400 + return nil
401 +}
402 +
403 +func (c *Collector) populateLicenseState(row *ddsnmp.LicenseRow, cfg ddprofiledefinition.LicenseStateConfig, ctx licenseValueContext) error {
404 + if !cfg.IsSet() {
405 + return nil
406 + }
407 +
408 + raw, _, err := c.licenseRawTextValue(cfg.LicenseValueConfig, ctx)
409 + if err != nil {
410 + return err
411 + }
412 + raw = licenseStateRawValueByPolicy(raw, cfg.Policy)
413 + severity, sourceOID, ok, err := c.licenseNumericValue(cfg.LicenseValueConfig, ctx)
414 + if err != nil {
415 + return err
416 + }
417 + if !ok {
418 + return nil
419 + }
420 +
421 + row.State = ddsnmp.LicenseState{
422 + Has: true,
423 + Severity: severity,
424 + Raw: raw,
425 + Policy: cfg.Policy,
426 + SourceOID: sourceOID,
427 + }
428 +
429 + return nil
430 +}
431 +
432 +func licenseStateRawValueByPolicy(raw string, policy ddprofiledefinition.LicenseStatePolicy) string {
433 + switch policy {
434 + case ddprofiledefinition.LicenseStatePolicySophos:
435 + switch strings.TrimSpace(raw) {
436 + case "0":
437 + return "none"
438 + case "1":
439 + return "trial"
440 + case "2":
441 + return "not_subscribed"
442 + case "3":
443 + return "subscribed"
444 + case "4":
445 + return "expired"
446 + case "5":
447 + return "deactivated"
448 + }
449 + }
450 + return raw
451 +}
452 +
453 +func (c *Collector) populateLicenseTimer(timer *ddsnmp.LicenseTimer, cfg ddprofiledefinition.LicenseTimerSignalsConfig, ctx licenseValueContext, name string) error {
454 + if cfg.LicenseValueConfig.IsSet() {
455 + if err := c.populateLicenseTimerTimestamp(timer, cfg.LicenseValueConfig, ctx, name); err != nil {
456 + return err
457 + }
458 + }
459 + if cfg.Timestamp.IsSet() {
460 + if err := c.populateLicenseTimerTimestamp(timer, cfg.Timestamp, ctx, name+".timestamp"); err != nil {
461 + return err
462 + }
463 + }
464 + if cfg.Remaining.IsSet() {
465 + if err := c.populateLicenseTimerRemaining(timer, cfg.Remaining, ctx, name+".remaining"); err != nil {
466 + return err
467 + }
468 + }
469 + return nil
470 +}
471 +
472 +func (c *Collector) populateLicenseTimerTimestamp(timer *ddsnmp.LicenseTimer, cfg ddprofiledefinition.LicenseValueConfig, ctx licenseValueContext, name string) error {
473 + value, sourceOID, ok, err := c.licenseNumericValue(cfg, ctx)
474 + if err != nil {
475 + return fmt.Errorf("%s: %w", name, err)
476 + }
477 + if !ok || licenseValueRejectedBySentinel(value, cfg.Sentinel) {
478 + return nil
479 + }
480 +
481 + timer.Has = true
482 + timer.Timestamp = value
483 + timer.SourceOID = sourceOID
484 + return nil
485 +}
486 +
487 +func (c *Collector) populateLicenseTimerRemaining(timer *ddsnmp.LicenseTimer, cfg ddprofiledefinition.LicenseValueConfig, ctx licenseValueContext, name string) error {
488 + value, sourceOID, ok, err := c.licenseNumericValue(cfg, ctx)
489 + if err != nil {
490 + return fmt.Errorf("%s: %w", name, err)
491 + }
492 + if !ok || licenseValueRejectedBySentinel(value, cfg.Sentinel) {
493 + return nil
494 + }
495 +
496 + timer.Has = true
497 + timer.RemainingSeconds = value
498 + timer.SourceOID = sourceOID
499 + return nil
500 +}
501 +
502 +func (c *Collector) populateLicenseUsage(usage *ddsnmp.LicenseUsage, cfg ddprofiledefinition.LicenseUsageSignalsConfig, ctx licenseValueContext) error {
503 + if err := c.populateLicenseUsageValue(&usage.HasUsed, &usage.Used, cfg.Used, ctx, "usage.used"); err != nil {
504 + return err
505 + }
506 + if err := c.populateLicenseUsageValue(&usage.HasCapacity, &usage.Capacity, cfg.Capacity, ctx, "usage.capacity"); err != nil {
507 + return err
508 + }
509 + if err := c.populateLicenseUsageValue(&usage.HasAvailable, &usage.Available, cfg.Available, ctx, "usage.available"); err != nil {
510 + return err
511 + }
512 + if err := c.populateLicenseUsageValue(&usage.HasPercent, &usage.Percent, cfg.Percent, ctx, "usage.percent"); err != nil {
513 + return err
514 + }
515 + return nil
516 +}
517 +
518 +func (c *Collector) populateLicenseUsageValue(has *bool, dst *int64, cfg ddprofiledefinition.LicenseValueConfig, ctx licenseValueContext, name string) error {
519 + if !cfg.IsSet() {
520 + return nil
521 + }
522 + value, _, ok, err := c.licenseNumericValue(cfg, ctx)
523 + if err != nil {
524 + return fmt.Errorf("%s: %w", name, err)
525 + }
526 + if !ok {
527 + return nil
528 + }
529 + if licenseValueRejectedBySentinel(value, cfg.Sentinel) {
530 + return nil
531 + }
532 + *has = true
533 + *dst = value
534 + return nil
535 +}
536 +
537 +func (c *Collector) licenseTextValue(cfg ddprofiledefinition.LicenseValueConfig, ctx licenseValueContext) (string, error) {
538 + value, ok, err := c.licenseRawTextValue(cfg, ctx)
539 + if err != nil || !ok {
540 + return "", err
541 + }
542 + sym := licenseValueSymbol(cfg)
543 + if mapped, ok := sym.Mapping.Lookup(value); ok {
544 + value = mapped
545 + }
546 + return value, nil
547 +}
548 +
549 +func (c *Collector) licenseRawTextValue(cfg ddprofiledefinition.LicenseValueConfig, ctx licenseValueContext) (string, bool, error) {
550 + if !cfg.IsSet() {
551 + return "", false, nil
552 + }
553 + if cfg.Value != "" {
554 + return cfg.Value, true, nil
555 + }
556 + if cfg.Index != 0 || len(cfg.IndexTransform) > 0 {
557 + value, err := c.licenseIndexValue(cfg, ctx.rowIndex)
558 + return value, err == nil, err
559 + }
560 +
561 + sym := licenseValueSymbol(cfg)
562 + if sym.OID == "" {
563 + return "", false, nil
564 + }
565 + pdu, ok := ctx.lookupPDU(sym.OID)
566 + if !ok {
567 + return "", false, nil
568 + }
569 +
570 + value, err := convPduToStringf(pdu, sym.Format)
571 + if err != nil {
572 + if errors.Is(err, errNoTextDateValue) {
573 + return "", false, nil
574 + }
575 + return "", false, err
576 + }
577 +
578 + if sym.ExtractValueCompiled != nil {
579 + sm := sym.ExtractValueCompiled.FindStringSubmatch(value)
580 + if len(sm) < 2 {
581 + return "", false, fmt.Errorf("extract_value did not match value %q", value)
582 + }
583 + value = sm[1]
584 + }
585 + if sym.MatchPatternCompiled != nil {
586 + sm := sym.MatchPatternCompiled.FindStringSubmatch(value)
587 + if len(sm) == 0 {
588 + return "", false, fmt.Errorf("match_pattern %q did not match value %q", sym.MatchPattern, value)
589 + }
590 + value = replaceSubmatches(sym.MatchValue, sm)
591 + }
592 +
593 + return value, true, nil
594 +}
595 +
596 +func (c *Collector) licenseNumericValue(cfg ddprofiledefinition.LicenseValueConfig, ctx licenseValueContext) (value int64, sourceOID string, ok bool, err error) {
597 + if !cfg.IsSet() {
598 + return 0, "", false, nil
599 + }
600 + if cfg.Value != "" || cfg.Index != 0 || len(cfg.IndexTransform) > 0 {
601 + text, err := c.licenseTextValue(cfg, ctx)
602 + if err != nil || text == "" {
603 + return 0, "", false, err
604 + }
605 + value, err := strconv.ParseInt(strings.TrimSpace(text), 10, 64)
606 + if err != nil {
607 + return 0, "", false, err
608 + }
609 + return value, "", true, nil
610 + }
611 +
612 + sym := licenseValueSymbol(cfg)
613 + if sym.OID == "" {
614 + return 0, "", false, nil
615 + }
616 + pdu, ok := ctx.lookupPDU(sym.OID)
617 + if !ok {
618 + return 0, "", false, nil
619 + }
620 +
621 + value, err = c.scalarCollector.valProc.processValue(sym, pdu)
622 + if err != nil {
623 + if errors.Is(err, errNoTextDateValue) {
624 + return 0, "", false, nil
625 + }
626 + return 0, "", false, err
627 + }
628 +
629 + return value, trimOID(sym.OID), true, nil
630 +}
631 +
632 +func (c *Collector) licenseBoolValue(cfg ddprofiledefinition.LicenseValueConfig, ctx licenseValueContext) (bool, error) {
633 + value, err := c.licenseTextValue(cfg, ctx)
634 + if err != nil || value == "" {
635 + return false, err
636 + }
637 + switch strings.ToLower(strings.TrimSpace(value)) {
638 + case "1", "true", "yes", "y", "on", "perpetual", "unlimited":
639 + return true, nil
640 + default:
641 + return false, nil
642 + }
643 +}
644 +
645 +func (c *Collector) licenseIndexValue(cfg ddprofiledefinition.LicenseValueConfig, rowIndex string) (string, error) {
646 + if rowIndex == "" {
647 + return "", nil
648 + }
649 + sym := licenseValueSymbol(cfg)
650 + tagCfg := ddprofiledefinition.MetricTagConfig{
651 + Index: cfg.Index,
652 + IndexTransform: cfg.IndexTransform,
653 + Symbol: ddprofiledefinition.SymbolConfigCompat(sym),
654 + Mapping: sym.Mapping,
655 + }
656 + _, value, err := c.tableCollector.rowProcessor.processIndexTag(tagCfg, rowIndex)
657 + return value, err
658 +}
659 +
660 +func (ctx licenseValueContext) lookupPDU(oid string) (gosnmp.SnmpPDU, bool) {
661 + oid = trimOID(oid)
662 + if ctx.rowPDUs != nil {
663 + pdu, ok := ctx.rowPDUs[oid]
664 + return pdu, ok
665 + }
666 + pdu, ok := ctx.pdus[oid]
667 + return pdu, ok
668 +}
669 +
670 +func licensingConfigAsMetricsConfig(cfg ddprofiledefinition.LicensingConfig) ddprofiledefinition.MetricsConfig {
671 + columns := make(map[string]ddprofiledefinition.SymbolConfig)
672 + addColumn := func(valueCfg ddprofiledefinition.LicenseValueConfig) {
673 + sym := licenseValueSymbol(valueCfg)
674 + if sym.OID != "" {
675 + columns[trimOID(sym.OID)] = sym
676 + }
677 + }
678 +
679 + forEachLicenseValue(cfg, addColumn)
680 + for _, tagCfg := range cfg.MetricTags {
681 + if tagCfg.Symbol.OID != "" {
682 + columns[trimOID(tagCfg.Symbol.OID)] = ddprofiledefinition.SymbolConfig(tagCfg.Symbol)
683 + }
684 + }
685 +
686 + symbols := make([]ddprofiledefinition.SymbolConfig, 0, len(columns))
687 + for _, sym := range columns {
688 + symbols = append(symbols, sym)
689 + }
690 +
691 + return ddprofiledefinition.MetricsConfig{
692 + MIB: licensingMetricsConfigMIB(cfg.MIB),
693 + Table: cfg.Table,
694 + Symbols: symbols,
695 + StaticTags: cfg.StaticTags,
696 + MetricTags: cfg.MetricTags,
697 + }
698 +}
699 +
700 +func licensingMetricsConfigMIB(mib string) string {
701 + if mib == "" {
702 + return "license"
703 + }
704 + return "license:" + mib
705 +}
706 +
707 +func licensingTableNameToOID(configs []ddprofiledefinition.LicensingConfig) map[string]string {
708 + tableNameToOID := make(map[string]string)
709 + crossTableOIDs := make(map[string][]string)
710 +
711 + for _, cfg := range configs {
712 + if cfg.Table.Name != "" && cfg.Table.OID != "" {
713 + tableNameToOID[cfg.Table.Name] = trimOID(cfg.Table.OID)
714 + }
715 + }
716 +
717 + for _, cfg := range configs {
718 + if cfg.Table.OID == "" {
719 + continue
720 + }
721 + metricsCfg := licensingConfigAsMetricsConfig(cfg)
722 + for _, tagCfg := range metricsCfg.MetricTags {
723 + if tagCfg.Table == "" || tagCfg.Table == cfg.Table.Name {
724 + continue
725 + }
726 + if _, ok := tableNameToOID[tagCfg.Table]; ok {
727 + continue
728 + }
729 + if tagCfg.Symbol.OID != "" {
730 + crossTableOIDs[tagCfg.Table] = append(crossTableOIDs[tagCfg.Table], tagCfg.Symbol.OID)
731 + }
732 + if tagCfg.LookupSymbol.OID != "" {
733 + crossTableOIDs[tagCfg.Table] = append(crossTableOIDs[tagCfg.Table], tagCfg.LookupSymbol.OID)
734 + }
735 + }
736 + }
737 +
738 + for tableName, oids := range crossTableOIDs {
739 + if oid := longestCommonOIDPrefix(oids); oid != "" {
740 + tableNameToOID[tableName] = oid
741 + }
742 + }
743 +
744 + return tableNameToOID
745 +}
746 +
747 +func longestCommonOIDPrefix(oids []string) string {
748 + if len(oids) == 0 {
749 + return ""
750 + }
751 + slices.Sort(oids)
752 + oids = slices.Compact(oids)
753 + prefixParts := splitOIDParts(oids[0])
754 + for _, oid := range oids[1:] {
755 + parts := splitOIDParts(oid)
756 + n := min(len(parts), len(prefixParts))
757 + i := 0
758 + for i < n && prefixParts[i] == parts[i] {
759 + i++
760 + }
761 + prefixParts = prefixParts[:i]
762 + if len(prefixParts) == 0 {
763 + return ""
764 + }
765 + }
766 + return strings.Join(prefixParts, ".")
767 +}
768 +
769 +func splitOIDParts(oid string) []string {
770 + parts := strings.Split(strings.Trim(oid, "."), ".")
771 + if len(parts) == 1 && parts[0] == "" {
772 + return nil
773 + }
774 + return parts
775 +}
776 +
777 +func (c *Collector) licensingScalarOIDs(cfg ddprofiledefinition.LicensingConfig) ([]string, []string) {
778 + oids := make(map[string]struct{})
779 + var missingOIDs []string
780 + addOID := func(valueCfg ddprofiledefinition.LicenseValueConfig) {
781 + sym := licenseValueSymbol(valueCfg)
782 + if sym.OID != "" {
783 + oid := trimOID(sym.OID)
784 + if c.missingOIDs[oid] {
785 + missingOIDs = append(missingOIDs, sym.OID)
786 + return
787 + }
788 + oids[oid] = struct{}{}
789 + }
790 + }
791 +
792 + forEachLicenseValue(cfg, addOID)
793 + for _, tagCfg := range cfg.MetricTags {
794 + if tagCfg.Symbol.OID != "" {
795 + oid := trimOID(tagCfg.Symbol.OID)
796 + if c.missingOIDs[oid] {
797 + missingOIDs = append(missingOIDs, tagCfg.Symbol.OID)
798 + continue
799 + }
800 + oids[oid] = struct{}{}
801 + }
802 + }
803 +
804 + result := make([]string, 0, len(oids))
805 + for oid := range oids {
806 + result = append(result, oid)
807 + }
808 + slices.Sort(result)
809 + slices.Sort(missingOIDs)
810 + missingOIDs = slices.Compact(missingOIDs)
811 + return result, missingOIDs
812 +}
813 +
814 +func forEachLicenseValue(cfg ddprofiledefinition.LicensingConfig, fn func(ddprofiledefinition.LicenseValueConfig)) {
815 + fn(cfg.Identity.ID)
816 + fn(cfg.Identity.Name)
817 + fn(cfg.Identity.Feature)
818 + fn(cfg.Identity.Component)
819 + fn(cfg.Descriptors.Type)
820 + fn(cfg.Descriptors.Impact)
821 + fn(cfg.Descriptors.Perpetual)
822 + fn(cfg.Descriptors.Unlimited)
823 + fn(cfg.State.LicenseValueConfig)
824 + forEachLicenseTimerValue(cfg.Signals.Expiry, fn)
825 + forEachLicenseTimerValue(cfg.Signals.Authorization, fn)
826 + forEachLicenseTimerValue(cfg.Signals.Certificate, fn)
827 + forEachLicenseTimerValue(cfg.Signals.Grace, fn)
828 + fn(cfg.Signals.Usage.Used)
829 + fn(cfg.Signals.Usage.Capacity)
830 + fn(cfg.Signals.Usage.Available)
831 + fn(cfg.Signals.Usage.Percent)
832 +}
833 +
834 +func forEachLicenseTimerValue(cfg ddprofiledefinition.LicenseTimerSignalsConfig, fn func(ddprofiledefinition.LicenseValueConfig)) {
835 + fn(cfg.LicenseValueConfig)
836 + fn(cfg.Timestamp)
837 + fn(cfg.Remaining)
838 +}
839 +
840 +func licenseValueSymbol(cfg ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.SymbolConfig {
841 + sym := cfg.Symbol
842 + if sym.OID == "" {
843 + switch {
844 + case cfg.From != "":
845 + sym.OID = cfg.From
846 + case cfg.OID != "":
847 + sym.OID = cfg.OID
848 + }
849 + }
850 + if sym.Name == "" {
851 + sym.Name = cfg.Name
852 + }
853 + if sym.Name == "" && sym.OID != "" {
854 + sym.Name = "license:" + trimOID(sym.OID)
855 + }
856 + if sym.Format == "" {
857 + sym.Format = cfg.Format
858 + }
859 + if !sym.Mapping.HasItems() && cfg.Mapping.HasItems() {
860 + sym.Mapping = cfg.Mapping
861 + }
862 + return sym
863 +}
864 +
865 +func scalarLicenseRowKey(cfg ddprofiledefinition.LicensingConfig) string {
866 + if cfg.ID != "" {
867 + return cfg.ID
868 + }
869 + rowKey := ""
870 + forEachLicenseValue(cfg, func(valueCfg ddprofiledefinition.LicenseValueConfig) {
871 + if rowKey != "" {
872 + return
873 + }
874 + if oid := licenseValueSymbol(valueCfg).OID; oid != "" {
875 + rowKey = trimOID(oid)
876 + }
877 + })
878 + if rowKey != "" {
879 + return rowKey
880 + }
881 + return cfg.OriginProfileID
882 +}
883 +
884 +func licensingConfigDisplayName(cfg ddprofiledefinition.LicensingConfig) string {
885 + if cfg.ID != "" {
886 + return cfg.ID
887 + }
888 + if cfg.Table.Name != "" {
889 + return cfg.Table.Name
890 + }
891 + if cfg.Table.OID != "" {
892 + return cfg.Table.OID
893 + }
894 + return cfg.OriginProfileID
895 +}
896 +
897 +func licenseRowHasSignals(row ddsnmp.LicenseRow) bool {
898 + return row.State.Has ||
899 + row.Expiry.Has ||
900 + row.Authorization.Has ||
901 + row.Certificate.Has ||
902 + row.Grace.Has ||
903 + row.Usage.HasUsed ||
904 + row.Usage.HasCapacity ||
905 + row.Usage.HasAvailable ||
906 + row.Usage.HasPercent
907 +}
908 +
909 +func licenseValueRejectedBySentinel(value int64, policies []ddprofiledefinition.LicenseSentinelPolicy) bool {
910 + for _, policy := range policies {
911 + switch policy {
912 + case ddprofiledefinition.LicenseSentinelTimerZeroOrNegative:
913 + if value <= 0 {
914 + return true
915 + }
916 + case ddprofiledefinition.LicenseSentinelTimerU32Max:
917 + if value == 4294967295 {
918 + return true
919 + }
920 + case ddprofiledefinition.LicenseSentinelTimerPre1971:
921 + if value > 0 && value < time.Date(1971, time.January, 1, 0, 0, 0, 0, time.UTC).Unix() {
922 + return true
923 + }
924 + }
925 + }
926 + return false
927 +}
928 +
929 +func licenseScalarStructuralID(originProfileID, rowKey string) string {
930 + return strings.Join([]string{originProfileID, "scalar", rowKey}, "|")
931 +}
932 +
933 +func licenseTableStructuralID(originProfileID, tableOID, rowKey string) string {
934 + return strings.Join([]string{originProfileID, "table", tableOID, rowKey}, "|")
935 +}
936 +
937 +func mergeStringMaps(dst, src map[string]string) {
938 + if dst == nil || len(src) == 0 {
939 + return
940 + }
941 + for k, v := range src {
942 + if v != "" {
943 + dst[k] = v
944 + }
945 + }
946 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_licensing_test.go new
+703
@@ -0,0 +1,703 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "testing"
7 + "time"
8 +
9 + "github.com/gosnmp/gosnmp"
10 + "github.com/stretchr/testify/assert"
11 + "github.com/stretchr/testify/require"
12 +
13 + "github.com/netdata/netdata/go/plugins/logger"
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/ddprofiledefinition"
16 +)
17 +
18 +func TestCollector_Collect_LicenseRowsFromScalarLicensingConfig(t *testing.T) {
19 + ctrl, mockHandler := setupMockHandler(t)
20 + defer ctrl.Finish()
21 +
22 + expectSNMPGet(mockHandler,
23 + []string{
24 + "1.3.6.1.4.1.99999.1.1.0",
25 + "1.3.6.1.4.1.99999.1.2.0",
26 + "1.3.6.1.4.1.99999.1.3.0",
27 + },
28 + []gosnmp.SnmpPDU{
29 + createStringPDU("1.3.6.1.4.1.99999.1.1.0", "active"),
30 + createStringPDU("1.3.6.1.4.1.99999.1.2.0", "2031-11-11"),
31 + createStringPDU("1.3.6.1.4.1.99999.1.3.0", "datacenter-a"),
32 + },
33 + )
34 +
35 + profile := &ddsnmp.Profile{
36 + SourceFile: "vendor-device.yaml",
37 + Definition: &ddprofiledefinition.ProfileDefinition{
38 + Licensing: []ddprofiledefinition.LicensingConfig{
39 + {
40 + OriginProfileID: "_vendor-licensing.yaml",
41 + ID: "scalar-license-group",
42 + Identity: ddprofiledefinition.LicenseIdentityConfig{
43 + ID: ddprofiledefinition.LicenseValueConfig{Value: "license-a"},
44 + Name: ddprofiledefinition.LicenseValueConfig{Value: "License A"},
45 + },
46 + Descriptors: ddprofiledefinition.LicenseDescriptorsConfig{
47 + Type: ddprofiledefinition.LicenseValueConfig{Value: "subscription"},
48 + Perpetual: ddprofiledefinition.LicenseValueConfig{Value: "false"},
49 + },
50 + State: ddprofiledefinition.LicenseStateConfig{
51 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
52 + Symbol: ddprofiledefinition.SymbolConfig{
53 + OID: "1.3.6.1.4.1.99999.1.1.0",
54 + Name: "licenseState",
55 + Mapping: ddprofiledefinition.NewExactMapping(map[string]string{
56 + "active": "0",
57 + }),
58 + },
59 + },
60 + Policy: ddprofiledefinition.LicenseStatePolicyDefault,
61 + },
62 + Signals: ddprofiledefinition.LicenseSignalsConfig{
63 + Expiry: ddprofiledefinition.LicenseTimerSignalsConfig{
64 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
65 + From: "1.3.6.1.4.1.99999.1.2.0",
66 + Format: "text_date",
67 + Sentinel: []ddprofiledefinition.LicenseSentinelPolicy{ddprofiledefinition.LicenseSentinelTimerZeroOrNegative},
68 + },
69 + },
70 + },
71 + MetricTags: ddprofiledefinition.MetricTagConfigList{
72 + {
73 + Tag: "license_site",
74 + Symbol: ddprofiledefinition.SymbolConfigCompat(ddprofiledefinition.SymbolConfig{
75 + OID: "1.3.6.1.4.1.99999.1.3.0",
76 + Name: "licenseSite",
77 + }),
78 + },
79 + },
80 + },
81 + },
82 + },
83 + }
84 +
85 + collector := New(Config{
86 + SnmpClient: mockHandler,
87 + Profiles: []*ddsnmp.Profile{profile},
88 + Log: logger.New(),
89 + })
90 +
91 + results, err := collector.Collect()
92 + require.NoError(t, err)
93 + require.Len(t, results, 1)
94 +
95 + pm := results[0]
96 + require.Empty(t, pm.HiddenMetrics)
97 + require.Empty(t, pm.Metrics)
98 + require.Empty(t, pm.TopologyMetrics)
99 + require.Len(t, pm.LicenseRows, 1)
100 +
101 + row := pm.LicenseRows[0]
102 + assert.Equal(t, "_vendor-licensing.yaml", row.OriginProfileID)
103 + assert.Equal(t, "scalar-license-group", row.RowKey)
104 + assert.Equal(t, "_vendor-licensing.yaml|scalar|scalar-license-group", row.StructuralID)
105 + assert.Equal(t, "license-a", row.ID)
106 + assert.Equal(t, "License A", row.Name)
107 + assert.Equal(t, "subscription", row.Type)
108 + assert.False(t, row.IsPerpetual)
109 + assert.True(t, row.State.Has)
110 + assert.EqualValues(t, 0, row.State.Severity)
111 + assert.Equal(t, "active", row.State.Raw)
112 + assert.Equal(t, "1.3.6.1.4.1.99999.1.1.0", row.State.SourceOID)
113 + assert.True(t, row.Expiry.Has)
114 + assert.Equal(t, time.Date(2031, time.November, 11, 0, 0, 0, 0, time.UTC).Unix(), row.Expiry.Timestamp)
115 + assert.Equal(t, "1.3.6.1.4.1.99999.1.2.0", row.Expiry.SourceOID)
116 + assert.Equal(t, map[string]string{"license_site": "datacenter-a"}, row.Tags)
117 +}
118 +
119 +func TestCollector_Collect_LicenseRowsBestEffortForRegularMetrics(t *testing.T) {
120 + tests := map[string]struct {
121 + licensePDU gosnmp.SnmpPDU
122 + }{
123 + "invalid licensing state does not drop scalar metric": {
124 + licensePDU: createStringPDU("1.3.6.1.4.1.99999.7.1.0", "not-a-number"),
125 + },
126 + }
127 +
128 + for name, tc := range tests {
129 + t.Run(name, func(t *testing.T) {
130 + ctrl, mockHandler := setupMockHandler(t)
131 + defer ctrl.Finish()
132 +
133 + expectSNMPGet(mockHandler,
134 + []string{"1.3.6.1.2.1.1.3.0"},
135 + []gosnmp.SnmpPDU{createTimeTicksPDU("1.3.6.1.2.1.1.3.0", 123456)},
136 + )
137 + expectSNMPGet(mockHandler,
138 + []string{"1.3.6.1.4.1.99999.7.1.0"},
139 + []gosnmp.SnmpPDU{tc.licensePDU},
140 + )
141 +
142 + collector := New(Config{
143 + SnmpClient: mockHandler,
144 + Profiles: []*ddsnmp.Profile{
145 + {
146 + SourceFile: "vendor-device.yaml",
147 + Definition: &ddprofiledefinition.ProfileDefinition{
148 + Metrics: []ddprofiledefinition.MetricsConfig{
149 + {
150 + Symbol: ddprofiledefinition.SymbolConfig{
151 + OID: "1.3.6.1.2.1.1.3.0",
152 + Name: "sysUpTime",
153 + },
154 + },
155 + },
156 + Licensing: []ddprofiledefinition.LicensingConfig{
157 + {
158 + OriginProfileID: "_vendor-licensing.yaml",
159 + ID: "bad-license-row",
160 + Identity: ddprofiledefinition.LicenseIdentityConfig{
161 + ID: ddprofiledefinition.LicenseValueConfig{Value: "bad-license-row"},
162 + },
163 + State: ddprofiledefinition.LicenseStateConfig{
164 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
165 + Symbol: ddprofiledefinition.SymbolConfig{
166 + OID: "1.3.6.1.4.1.99999.7.1.0",
167 + Name: "licenseState",
168 + },
169 + },
170 + },
171 + },
172 + },
173 + },
174 + },
175 + },
176 + Log: logger.New(),
177 + })
178 +
179 + results, err := collector.Collect()
180 + require.NoError(t, err)
181 + require.Len(t, results, 1)
182 + require.Len(t, results[0].Metrics, 1)
183 + assert.Equal(t, "sysUpTime", results[0].Metrics[0].Name)
184 + assert.Empty(t, results[0].LicenseRows)
185 + assert.EqualValues(t, 1, results[0].Stats.Errors.Processing.Licensing)
186 + })
187 + }
188 +}
189 +
190 +func TestCollector_Collect_LicenseRowsSkipsKnownMissingScalarOIDs(t *testing.T) {
191 + tests := map[string]struct {
192 + missingPDU gosnmp.SnmpPDU
193 + }{
194 + "no such object cached after first licensing poll": {
195 + missingPDU: createNoSuchObjectPDU("1.3.6.1.4.1.99999.8.1.0"),
196 + },
197 + }
198 +
199 + for name, tc := range tests {
200 + t.Run(name, func(t *testing.T) {
201 + ctrl, mockHandler := setupMockHandler(t)
202 + defer ctrl.Finish()
203 +
204 + expectSNMPGet(mockHandler,
205 + []string{"1.3.6.1.2.1.1.3.0"},
206 + []gosnmp.SnmpPDU{createTimeTicksPDU("1.3.6.1.2.1.1.3.0", 123456)},
207 + )
208 + expectSNMPGet(mockHandler,
209 + []string{"1.3.6.1.4.1.99999.8.1.0"},
210 + []gosnmp.SnmpPDU{tc.missingPDU},
211 + )
212 + expectSNMPGet(mockHandler,
213 + []string{"1.3.6.1.2.1.1.3.0"},
214 + []gosnmp.SnmpPDU{createTimeTicksPDU("1.3.6.1.2.1.1.3.0", 223456)},
215 + )
216 +
217 + collector := New(Config{
218 + SnmpClient: mockHandler,
219 + Profiles: []*ddsnmp.Profile{
220 + {
221 + SourceFile: "vendor-device.yaml",
222 + Definition: &ddprofiledefinition.ProfileDefinition{
223 + Metrics: []ddprofiledefinition.MetricsConfig{
224 + {
225 + Symbol: ddprofiledefinition.SymbolConfig{
226 + OID: "1.3.6.1.2.1.1.3.0",
227 + Name: "sysUpTime",
228 + },
229 + },
230 + },
231 + Licensing: []ddprofiledefinition.LicensingConfig{
232 + {
233 + OriginProfileID: "_vendor-licensing.yaml",
234 + ID: "missing-license-row",
235 + Identity: ddprofiledefinition.LicenseIdentityConfig{
236 + ID: ddprofiledefinition.LicenseValueConfig{Value: "missing-license-row"},
237 + },
238 + State: ddprofiledefinition.LicenseStateConfig{
239 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
240 + Symbol: ddprofiledefinition.SymbolConfig{
241 + OID: "1.3.6.1.4.1.99999.8.1.0",
242 + Name: "licenseState",
243 + },
244 + },
245 + },
246 + },
247 + },
248 + },
249 + },
250 + },
251 + Log: logger.New(),
252 + })
253 +
254 + first, err := collector.Collect()
255 + require.NoError(t, err)
256 + require.Len(t, first, 1)
257 + assert.Empty(t, first[0].LicenseRows)
258 + assert.EqualValues(t, 2, first[0].Stats.SNMP.GetOIDs)
259 + assert.EqualValues(t, 1, first[0].Stats.Errors.MissingOIDs)
260 +
261 + second, err := collector.Collect()
262 + require.NoError(t, err)
263 + require.Len(t, second, 1)
264 + assert.Empty(t, second[0].LicenseRows)
265 + assert.EqualValues(t, 1, second[0].Stats.SNMP.GetOIDs)
266 + assert.EqualValues(t, 1, second[0].Stats.Errors.MissingOIDs)
267 + })
268 + }
269 +}
270 +
271 +func TestCollector_Collect_LicenseRowsFromTableLicensingConfig(t *testing.T) {
272 + ctrl, mockHandler := setupMockHandler(t)
273 + defer ctrl.Finish()
274 +
275 + expectSNMPWalk(mockHandler,
276 + gosnmp.Version2c,
277 + "1.3.6.1.4.1.99999.2",
278 + []gosnmp.SnmpPDU{
279 + createStringPDU("1.3.6.1.4.1.99999.2.1.1", "firewall"),
280 + createStringPDU("1.3.6.1.4.1.99999.2.2.1", "Firewall subscription"),
281 + createStringPDU("1.3.6.1.4.1.99999.2.3.1", "healthy"),
282 + createGauge32PDU("1.3.6.1.4.1.99999.2.4.1", 3600),
283 + createGauge32PDU("1.3.6.1.4.1.99999.2.5.1", 10),
284 + createGauge32PDU("1.3.6.1.4.1.99999.2.6.1", 25),
285 + createStringPDU("1.3.6.1.4.1.99999.2.1.2", "vpn"),
286 + createStringPDU("1.3.6.1.4.1.99999.2.2.2", "VPN subscription"),
287 + createStringPDU("1.3.6.1.4.1.99999.2.3.2", "warning"),
288 + createGauge32PDU("1.3.6.1.4.1.99999.2.4.2", 7200),
289 + createGauge32PDU("1.3.6.1.4.1.99999.2.5.2", 50),
290 + createGauge32PDU("1.3.6.1.4.1.99999.2.6.2", 100),
291 + },
292 + )
293 +
294 + profile := &ddsnmp.Profile{
295 + SourceFile: "vendor-device.yaml",
296 + Definition: &ddprofiledefinition.ProfileDefinition{
297 + Licensing: []ddprofiledefinition.LicensingConfig{
298 + {
299 + OriginProfileID: "_vendor-licensing.yaml",
300 + ID: "table-license",
301 + Table: ddprofiledefinition.SymbolConfig{
302 + OID: "1.3.6.1.4.1.99999.2",
303 + Name: "licenseTable",
304 + },
305 + Identity: ddprofiledefinition.LicenseIdentityConfig{
306 + ID: ddprofiledefinition.LicenseValueConfig{
307 + Index: 1,
308 + },
309 + Name: ddprofiledefinition.LicenseValueConfig{
310 + Symbol: ddprofiledefinition.SymbolConfig{
311 + OID: "1.3.6.1.4.1.99999.2.2",
312 + Name: "licenseName",
313 + },
314 + },
315 + },
316 + State: ddprofiledefinition.LicenseStateConfig{
317 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
318 + Symbol: ddprofiledefinition.SymbolConfig{
319 + OID: "1.3.6.1.4.1.99999.2.3",
320 + Name: "licenseState",
321 + Mapping: ddprofiledefinition.NewExactMapping(map[string]string{
322 + "healthy": "0",
323 + "warning": "1",
324 + }),
325 + },
326 + },
327 + Policy: ddprofiledefinition.LicenseStatePolicyDefault,
328 + },
329 + Signals: ddprofiledefinition.LicenseSignalsConfig{
330 + Expiry: ddprofiledefinition.LicenseTimerSignalsConfig{
331 + Remaining: ddprofiledefinition.LicenseValueConfig{
332 + Symbol: ddprofiledefinition.SymbolConfig{
333 + OID: "1.3.6.1.4.1.99999.2.4",
334 + Name: "licenseExpiryRemaining",
335 + },
336 + Sentinel: []ddprofiledefinition.LicenseSentinelPolicy{ddprofiledefinition.LicenseSentinelTimerZeroOrNegative},
337 + },
338 + },
339 + Usage: ddprofiledefinition.LicenseUsageSignalsConfig{
340 + Used: ddprofiledefinition.LicenseValueConfig{
341 + Symbol: ddprofiledefinition.SymbolConfig{
342 + OID: "1.3.6.1.4.1.99999.2.5",
343 + Name: "licenseUsed",
344 + },
345 + },
346 + Capacity: ddprofiledefinition.LicenseValueConfig{
347 + Symbol: ddprofiledefinition.SymbolConfig{
348 + OID: "1.3.6.1.4.1.99999.2.6",
349 + Name: "licenseCapacity",
350 + },
351 + },
352 + },
353 + },
354 + StaticTags: []ddprofiledefinition.StaticMetricTagConfig{
355 + {Tag: "license_vendor", Value: "test"},
356 + },
357 + MetricTags: ddprofiledefinition.MetricTagConfigList{
358 + {
359 + Tag: "license_feature",
360 + Index: 1,
361 + },
362 + },
363 + },
364 + },
365 + },
366 + }
367 +
368 + collector := New(Config{
369 + SnmpClient: mockHandler,
370 + Profiles: []*ddsnmp.Profile{profile},
371 + Log: logger.New(),
372 + })
373 +
374 + results, err := collector.Collect()
375 + require.NoError(t, err)
376 + require.Len(t, results, 1)
377 +
378 + pm := results[0]
379 + require.Empty(t, pm.HiddenMetrics)
380 + require.Empty(t, pm.Metrics)
381 + require.Empty(t, pm.TopologyMetrics)
382 + require.Len(t, pm.LicenseRows, 2)
383 +
384 + rowsByID := make(map[string]ddsnmp.LicenseRow, len(pm.LicenseRows))
385 + for _, row := range pm.LicenseRows {
386 + if _, ok := rowsByID[row.ID]; ok {
387 + t.Fatalf("duplicate license row id %q", row.ID)
388 + }
389 + rowsByID[row.ID] = row
390 + }
391 +
392 + firewall := rowsByID["1"]
393 + assert.Equal(t, "licenseTable", firewall.Table)
394 + assert.Equal(t, "1.3.6.1.4.1.99999.2", firewall.TableOID)
395 + assert.Equal(t, "1", firewall.RowKey)
396 + assert.Equal(t, "_vendor-licensing.yaml|table|1.3.6.1.4.1.99999.2|1", firewall.StructuralID)
397 + assert.Equal(t, "Firewall subscription", firewall.Name)
398 + assert.True(t, firewall.State.Has)
399 + assert.EqualValues(t, 0, firewall.State.Severity)
400 + assert.Equal(t, "healthy", firewall.State.Raw)
401 + assert.True(t, firewall.Expiry.Has)
402 + assert.EqualValues(t, 3600, firewall.Expiry.RemainingSeconds)
403 + assert.True(t, firewall.Usage.HasUsed)
404 + assert.EqualValues(t, 10, firewall.Usage.Used)
405 + assert.True(t, firewall.Usage.HasCapacity)
406 + assert.EqualValues(t, 25, firewall.Usage.Capacity)
407 + assert.Equal(t, map[string]string{
408 + "license_vendor": "test",
409 + "license_feature": "1",
410 + }, firewall.Tags)
411 +
412 + vpn := rowsByID["2"]
413 + assert.Equal(t, "VPN subscription", vpn.Name)
414 + assert.EqualValues(t, 1, vpn.State.Severity)
415 + assert.Equal(t, "warning", vpn.State.Raw)
416 + assert.EqualValues(t, 7200, vpn.Expiry.RemainingSeconds)
417 + assert.EqualValues(t, 50, vpn.Usage.Used)
418 + assert.EqualValues(t, 100, vpn.Usage.Capacity)
419 +}
420 +
421 +func TestCollector_Collect_LicenseRowsFromTableLicensingConfig_ResolvesCrossTableTags(t *testing.T) {
422 + ctrl, mockHandler := setupMockHandler(t)
423 + defer ctrl.Finish()
424 +
425 + expectSNMPWalk(mockHandler,
426 + gosnmp.Version2c,
427 + "1.3.6.1.4.1.99999.6",
428 + []gosnmp.SnmpPDU{
429 + createIntegerPDU("1.3.6.1.4.1.99999.6.1.1", 0),
430 + createIntegerPDU("1.3.6.1.4.1.99999.6.1.2", 1),
431 + },
432 + )
433 + expectSNMPWalk(mockHandler,
434 + gosnmp.Version2c,
435 + "1.3.6.1.2.1.31.1.1.1.1",
436 + []gosnmp.SnmpPDU{
437 + createStringPDU("1.3.6.1.2.1.31.1.1.1.1.1", "eth1"),
438 + createStringPDU("1.3.6.1.2.1.31.1.1.1.1.2", "eth2"),
439 + },
440 + )
441 +
442 + profile := &ddsnmp.Profile{
443 + SourceFile: "vendor-device.yaml",
444 + Definition: &ddprofiledefinition.ProfileDefinition{
445 + Licensing: []ddprofiledefinition.LicensingConfig{
446 + {
447 + OriginProfileID: "_vendor-licensing.yaml",
448 + Table: ddprofiledefinition.SymbolConfig{
449 + OID: "1.3.6.1.4.1.99999.6",
450 + Name: "licenseIfTable",
451 + },
452 + Identity: ddprofiledefinition.LicenseIdentityConfig{
453 + ID: ddprofiledefinition.LicenseValueConfig{Index: 1},
454 + },
455 + State: ddprofiledefinition.LicenseStateConfig{
456 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
457 + Symbol: ddprofiledefinition.SymbolConfig{
458 + OID: "1.3.6.1.4.1.99999.6.1",
459 + Name: "licenseState",
460 + },
461 + },
462 + Policy: ddprofiledefinition.LicenseStatePolicyDefault,
463 + },
464 + MetricTags: ddprofiledefinition.MetricTagConfigList{
465 + {
466 + Tag: "if_name",
467 + Table: "ifXTable",
468 + Symbol: ddprofiledefinition.SymbolConfigCompat{
469 + OID: "1.3.6.1.2.1.31.1.1.1.1",
470 + Name: "ifName",
471 + },
472 + },
473 + },
474 + },
475 + },
476 + },
477 + }
478 +
479 + collector := New(Config{
480 + SnmpClient: mockHandler,
481 + Profiles: []*ddsnmp.Profile{profile},
482 + Log: logger.New(),
483 + })
484 +
485 + results, err := collector.Collect()
486 + require.NoError(t, err)
487 + require.Len(t, results, 1)
488 + require.Len(t, results[0].LicenseRows, 2)
489 + assert.EqualValues(t, 2, results[0].Stats.SNMP.TablesWalked)
490 +
491 + rowsByID := make(map[string]ddsnmp.LicenseRow, len(results[0].LicenseRows))
492 + for _, row := range results[0].LicenseRows {
493 + rowsByID[row.ID] = row
494 + }
495 + assert.Equal(t, map[string]string{"if_name": "eth1"}, rowsByID["1"].Tags)
496 + assert.Equal(t, map[string]string{"if_name": "eth2"}, rowsByID["2"].Tags)
497 +}
498 +
499 +func TestCollector_Collect_LicenseRowsFromTableLicensingConfig_UsesTableCache(t *testing.T) {
500 + ctrl, mockHandler := setupMockHandler(t)
501 + defer ctrl.Finish()
502 +
503 + expectSNMPWalk(mockHandler,
504 + gosnmp.Version2c,
505 + "1.3.6.1.4.1.99999.3",
506 + []gosnmp.SnmpPDU{
507 + createStringPDU("1.3.6.1.4.1.99999.3.1.7", "security"),
508 + createStringPDU("1.3.6.1.4.1.99999.3.2.7", "Security subscription"),
509 + createIntegerPDU("1.3.6.1.4.1.99999.3.3.7", 0),
510 + },
511 + )
512 + expectSNMPGet(mockHandler,
513 + []string{
514 + "1.3.6.1.4.1.99999.3.1.7",
515 + "1.3.6.1.4.1.99999.3.2.7",
516 + "1.3.6.1.4.1.99999.3.3.7",
517 + },
518 + []gosnmp.SnmpPDU{
519 + createStringPDU("1.3.6.1.4.1.99999.3.1.7", "security"),
520 + createStringPDU("1.3.6.1.4.1.99999.3.2.7", "Security subscription"),
521 + createIntegerPDU("1.3.6.1.4.1.99999.3.3.7", 1),
522 + },
523 + )
524 +
525 + profile := &ddsnmp.Profile{
526 + SourceFile: "vendor-device.yaml",
527 + Definition: &ddprofiledefinition.ProfileDefinition{
528 + Licensing: []ddprofiledefinition.LicensingConfig{
529 + {
530 + OriginProfileID: "_vendor-licensing.yaml",
531 + Table: ddprofiledefinition.SymbolConfig{
532 + OID: "1.3.6.1.4.1.99999.3",
533 + Name: "cachedLicenseTable",
534 + },
535 + Identity: ddprofiledefinition.LicenseIdentityConfig{
536 + ID: ddprofiledefinition.LicenseValueConfig{
537 + Symbol: ddprofiledefinition.SymbolConfig{
538 + OID: "1.3.6.1.4.1.99999.3.1",
539 + Name: "licenseID",
540 + },
541 + },
542 + Name: ddprofiledefinition.LicenseValueConfig{
543 + Symbol: ddprofiledefinition.SymbolConfig{
544 + OID: "1.3.6.1.4.1.99999.3.2",
545 + Name: "licenseName",
546 + },
547 + },
548 + },
549 + State: ddprofiledefinition.LicenseStateConfig{
550 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
551 + Symbol: ddprofiledefinition.SymbolConfig{
552 + OID: "1.3.6.1.4.1.99999.3.3",
553 + Name: "licenseState",
554 + },
555 + },
556 + Policy: ddprofiledefinition.LicenseStatePolicyDefault,
557 + },
558 + },
559 + },
560 + },
561 + }
562 +
563 + collector := New(Config{
564 + SnmpClient: mockHandler,
565 + Profiles: []*ddsnmp.Profile{profile},
566 + Log: logger.New(),
567 + })
568 +
569 + first, err := collector.Collect()
570 + require.NoError(t, err)
571 + require.Len(t, first, 1)
572 + require.Len(t, first[0].LicenseRows, 1)
573 + assert.EqualValues(t, 0, first[0].LicenseRows[0].State.Severity)
574 + assert.EqualValues(t, 1, first[0].Stats.TableCache.Misses)
575 + assert.EqualValues(t, 0, first[0].Stats.TableCache.Hits)
576 +
577 + second, err := collector.Collect()
578 + require.NoError(t, err)
579 + require.Len(t, second, 1)
580 + require.Len(t, second[0].LicenseRows, 1)
581 + assert.EqualValues(t, 1, second[0].LicenseRows[0].State.Severity)
582 + assert.EqualValues(t, 0, second[0].Stats.TableCache.Misses)
583 + assert.EqualValues(t, 1, second[0].Stats.TableCache.Hits)
584 + assert.EqualValues(t, 1, second[0].Stats.SNMP.TablesCached)
585 +}
586 +
587 +func TestCollector_Collect_LicenseRowsRejectsSentinelValues(t *testing.T) {
588 + tests := map[string]struct {
589 + value int
590 + signals func(ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.LicenseSignalsConfig
591 + }{
592 + "expiry timestamp zero": {
593 + value: 0,
594 + signals: func(value ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.LicenseSignalsConfig {
595 + return ddprofiledefinition.LicenseSignalsConfig{
596 + Expiry: ddprofiledefinition.LicenseTimerSignalsConfig{Timestamp: value},
597 + }
598 + },
599 + },
600 + "expiry timestamp u32 max": {
601 + value: 4294967295,
602 + signals: func(value ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.LicenseSignalsConfig {
603 + value.Sentinel = []ddprofiledefinition.LicenseSentinelPolicy{ddprofiledefinition.LicenseSentinelTimerU32Max}
604 + return ddprofiledefinition.LicenseSignalsConfig{
605 + Expiry: ddprofiledefinition.LicenseTimerSignalsConfig{Timestamp: value},
606 + }
607 + },
608 + },
609 + "expiry timestamp pre 1971": {
610 + value: 1,
611 + signals: func(value ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.LicenseSignalsConfig {
612 + value.Sentinel = []ddprofiledefinition.LicenseSentinelPolicy{ddprofiledefinition.LicenseSentinelTimerPre1971}
613 + return ddprofiledefinition.LicenseSignalsConfig{
614 + Expiry: ddprofiledefinition.LicenseTimerSignalsConfig{Timestamp: value},
615 + }
616 + },
617 + },
618 + "expiry remaining zero": {
619 + value: 0,
620 + signals: func(value ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.LicenseSignalsConfig {
621 + return ddprofiledefinition.LicenseSignalsConfig{
622 + Expiry: ddprofiledefinition.LicenseTimerSignalsConfig{Remaining: value},
623 + }
624 + },
625 + },
626 + "expiry remaining u32 max": {
627 + value: 4294967295,
628 + signals: func(value ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.LicenseSignalsConfig {
629 + value.Sentinel = []ddprofiledefinition.LicenseSentinelPolicy{ddprofiledefinition.LicenseSentinelTimerU32Max}
630 + return ddprofiledefinition.LicenseSignalsConfig{
631 + Expiry: ddprofiledefinition.LicenseTimerSignalsConfig{Remaining: value},
632 + }
633 + },
634 + },
635 + "expiry remaining pre 1971": {
636 + value: 1,
637 + signals: func(value ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.LicenseSignalsConfig {
638 + value.Sentinel = []ddprofiledefinition.LicenseSentinelPolicy{ddprofiledefinition.LicenseSentinelTimerPre1971}
639 + return ddprofiledefinition.LicenseSignalsConfig{
640 + Expiry: ddprofiledefinition.LicenseTimerSignalsConfig{Remaining: value},
641 + }
642 + },
643 + },
644 + "usage used pre 1971": {
645 + value: 1,
646 + signals: func(value ddprofiledefinition.LicenseValueConfig) ddprofiledefinition.LicenseSignalsConfig {
647 + value.Sentinel = []ddprofiledefinition.LicenseSentinelPolicy{ddprofiledefinition.LicenseSentinelTimerPre1971}
648 + return ddprofiledefinition.LicenseSignalsConfig{
649 + Usage: ddprofiledefinition.LicenseUsageSignalsConfig{Used: value},
650 + }
651 + },
652 + },
653 + }
654 +
655 + for name, tc := range tests {
656 + t.Run(name, func(t *testing.T) {
657 + ctrl, mockHandler := setupMockHandler(t)
658 + defer ctrl.Finish()
659 +
660 + expectSNMPGet(mockHandler,
661 + []string{"1.3.6.1.4.1.99999.4.1.0"},
662 + []gosnmp.SnmpPDU{
663 + createGauge32PDU("1.3.6.1.4.1.99999.4.1.0", uint(tc.value)),
664 + },
665 + )
666 +
667 + value := ddprofiledefinition.LicenseValueConfig{
668 + Symbol: ddprofiledefinition.SymbolConfig{
669 + OID: "1.3.6.1.4.1.99999.4.1.0",
670 + Name: "sentinelValue",
671 + },
672 + Sentinel: []ddprofiledefinition.LicenseSentinelPolicy{ddprofiledefinition.LicenseSentinelTimerZeroOrNegative},
673 + }
674 + profile := &ddsnmp.Profile{
675 + SourceFile: "vendor-device.yaml",
676 + Definition: &ddprofiledefinition.ProfileDefinition{
677 + Licensing: []ddprofiledefinition.LicensingConfig{
678 + {
679 + OriginProfileID: "_vendor-licensing.yaml",
680 + ID: "sentinel-row",
681 + Identity: ddprofiledefinition.LicenseIdentityConfig{
682 + ID: ddprofiledefinition.LicenseValueConfig{Value: "sentinel-row"},
683 + },
684 + Signals: tc.signals(value),
685 + },
686 + },
687 + },
688 + }
689 +
690 + collector := New(Config{
691 + SnmpClient: mockHandler,
692 + Profiles: []*ddsnmp.Profile{profile},
693 + Log: logger.New(),
694 + })
695 +
696 + results, err := collector.Collect()
697 + require.NoError(t, err)
698 + require.Len(t, results, 1)
699 + assert.Empty(t, results[0].LicenseRows)
700 + assert.Zero(t, results[0].Stats.Metrics.Licensing)
701 + })
702 + }
703 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_scalar_test.go
+10 -10
@@ -111,18 +111,18 @@ func TestScalarCollector_Collect(t *testing.T) {
111 {
112 Symbol: ddprofiledefinition.SymbolConfig{
113 OID: "1.3.6.1.4.1.2604.5.1.5.1.1.0",
114 - Name: "_license_row",
114 + Name: "device.license.state",
115 Mapping: ddprofiledefinition.NewExactMapping(map[string]string{
116 "1": "1",
117 "4": "2",
118 }),
119 },
120 StaticTags: []ddprofiledefinition.StaticMetricTagConfig{
121 - {Tag: "_license_id", Value: "base_firewall"},
121 + {Tag: "component", Value: "base_firewall"},
122 },
123 MetricTags: []ddprofiledefinition.MetricTagConfig{
124 {
125 - Tag: "license_state",
125 + Tag: "state",
126 Symbol: ddprofiledefinition.SymbolConfigCompat{
127 OID: "1.3.6.1.4.1.2604.5.1.5.1.1.0",
128 },
@@ -132,7 +132,7 @@ func TestScalarCollector_Collect(t *testing.T) {
132 }),
133 },
134 {
135 - Tag: "license_expiry",
135 + Tag: "expiry",
136 Symbol: ddprofiledefinition.SymbolConfigCompat{
137 OID: "1.3.6.1.4.1.2604.5.1.5.1.2.0",
138 },
@@ -150,16 +150,16 @@ func TestScalarCollector_Collect(t *testing.T) {
150 },
151 expectedResult: []ddsnmp.Metric{
152 {
153 - Name: "_license_row",
153 + Name: "device.license.state",
154 Value: 2,
155 MetricType: "gauge",
156 Tags: map[string]string{
157 - "_license_id": "base_firewall",
158 - "license_state": "expired",
159 - "license_expiry": "11 Nov 2031",
157 + "component": "base_firewall",
158 + "state": "expired",
159 + "expiry": "11 Nov 2031",
160 },
161 StaticTags: map[string]string{
162 - "_license_id": "base_firewall",
162 + "component": "base_firewall",
163 },
164 },
165 },
@@ -894,7 +894,7 @@ func TestScalarCollector_IdentifyScalarOIDs_SkipsTagOIDsWhenPrimaryOIDIsKnownMis
894 {
895 Symbol: ddprofiledefinition.SymbolConfig{
896 OID: "1.3.6.1.4.1.2604.5.1.5.1.1.0",
897 - Name: "_license_row",
897 + Name: "primary_metric",
898 },
899 MetricTags: []ddprofiledefinition.MetricTagConfig{
900 {
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_sophos_test.go new
+158
@@ -0,0 +1,158 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "testing"
7 +
8 + "github.com/gosnmp/gosnmp"
9 + "github.com/netdata/netdata/go/plugins/logger"
10 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp/ddprofiledefinition"
12 + "github.com/stretchr/testify/assert"
13 + "github.com/stretchr/testify/require"
14 +)
15 +
16 +func TestCollector_Collect_SophosLicensingProfile_PreservesRawStateAndExpiryOnScalarRows(t *testing.T) {
17 + ctrl, mockHandler := setupMockHandler(t)
18 + defer ctrl.Finish()
19 +
20 + expectSNMPGet(mockHandler, []string{
21 + "1.3.6.1.4.1.2604.5.1.5.1.1.0",
22 + "1.3.6.1.4.1.2604.5.1.5.1.2.0",
23 + }, []gosnmp.SnmpPDU{
24 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.1.1.0", 3),
25 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.1.2.0", "2026-12-31"),
26 + })
27 + expectSNMPGet(mockHandler, []string{
28 + "1.3.6.1.4.1.2604.5.1.5.2.1.0",
29 + "1.3.6.1.4.1.2604.5.1.5.2.2.0",
30 + }, []gosnmp.SnmpPDU{
31 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.2.1.0", 4),
32 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.2.2.0", "2026-11-30"),
33 + })
34 + expectSNMPGet(mockHandler, []string{
35 + "1.3.6.1.4.1.2604.5.1.5.3.1.0",
36 + "1.3.6.1.4.1.2604.5.1.5.3.2.0",
37 + }, []gosnmp.SnmpPDU{
38 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.3.1.0", 2),
39 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.3.2.0", "N/A"),
40 + })
41 + expectSNMPGet(mockHandler, []string{
42 + "1.3.6.1.4.1.2604.5.1.5.4.1.0",
43 + "1.3.6.1.4.1.2604.5.1.5.4.2.0",
44 + }, []gosnmp.SnmpPDU{
45 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.4.1.0", 1),
46 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.4.2.0", "2026-10-15"),
47 + })
48 + expectSNMPGet(mockHandler, []string{
49 + "1.3.6.1.4.1.2604.5.1.5.5.1.0",
50 + "1.3.6.1.4.1.2604.5.1.5.5.2.0",
51 + }, []gosnmp.SnmpPDU{
52 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.5.1.0", 3),
53 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.5.2.0", "2027-01-20"),
54 + })
55 + expectSNMPGet(mockHandler, []string{
56 + "1.3.6.1.4.1.2604.5.1.5.6.1.0",
57 + "1.3.6.1.4.1.2604.5.1.5.6.2.0",
58 + }, []gosnmp.SnmpPDU{
59 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.6.1.0", 5),
60 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.6.2.0", "2026-08-15"),
61 + })
62 + expectSNMPGet(mockHandler, []string{
63 + "1.3.6.1.4.1.2604.5.1.5.7.1.0",
64 + "1.3.6.1.4.1.2604.5.1.5.7.2.0",
65 + }, []gosnmp.SnmpPDU{
66 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.7.1.0", 3),
67 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.7.2.0", "2026-09-01"),
68 + })
69 + expectSNMPGet(mockHandler, []string{
70 + "1.3.6.1.4.1.2604.5.1.5.8.1.0",
71 + "1.3.6.1.4.1.2604.5.1.5.8.2.0",
72 + }, []gosnmp.SnmpPDU{
73 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.8.1.0", 0),
74 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.8.2.0", "Never"),
75 + })
76 + expectSNMPGet(mockHandler, []string{
77 + "1.3.6.1.4.1.2604.5.1.5.9.1.0",
78 + "1.3.6.1.4.1.2604.5.1.5.9.2.0",
79 + }, []gosnmp.SnmpPDU{
80 + createIntegerPDU("1.3.6.1.4.1.2604.5.1.5.9.1.0", 3),
81 + createStringPDU("1.3.6.1.4.1.2604.5.1.5.9.2.0", "2026-07-01"),
82 + })
83 +
84 + profile := mustLoadTypedLicensingProfile(t, "sophos-xgs-firewall", func(row ddprofiledefinition.LicensingConfig) bool {
85 + return row.MIB == "SFOS-FIREWALL-MIB"
86 + })
87 + require.Len(t, profile.Definition.Licensing, 9)
88 +
89 + collector := New(Config{
90 + SnmpClient: mockHandler,
91 + Profiles: []*ddsnmp.Profile{profile},
92 + Log: logger.New(),
93 + SysObjectID: "",
94 + })
95 +
96 + results, err := collector.Collect()
97 + require.NoError(t, err)
98 + require.Len(t, results, 1)
99 +
100 + pm := results[0]
101 + assert.Empty(t, pm.Metrics)
102 + assert.Empty(t, pm.HiddenMetrics)
103 + require.Len(t, pm.LicenseRows, 9)
104 + assert.EqualValues(t, 9, pm.Stats.Metrics.Licensing)
105 +
106 + byID := licenseRowsByID(pm.LicenseRows)
107 + require.Len(t, byID, 9)
108 +
109 + expectations := map[string]struct {
110 + severity int64
111 + state string
112 + kind string
113 + expiryTS int64
114 + hasExpiry bool
115 + }{
116 + "base_firewall": {severity: 0, state: "subscribed", kind: "subscription", expiryTS: 1798675200, hasExpiry: true},
117 + "network_protection": {severity: 2, state: "expired", kind: "subscription", expiryTS: 1795996800, hasExpiry: true},
118 + "web_protection": {severity: 0, state: "not_subscribed", kind: "subscription"},
119 + "mail_protection": {severity: 1, state: "trial", kind: "subscription", expiryTS: 1792022400, hasExpiry: true},
120 + "web_server_protection": {severity: 0, state: "subscribed", kind: "subscription", expiryTS: 1800403200, hasExpiry: true},
121 + "sandstorm": {severity: 2, state: "deactivated", kind: "subscription", expiryTS: 1786752000, hasExpiry: true},
122 + "enhanced_support": {severity: 0, state: "subscribed", kind: "support", expiryTS: 1788220800, hasExpiry: true},
123 + "enhanced_plus_support": {severity: 0, state: "none", kind: "support"},
124 + "central_orchestration": {severity: 0, state: "subscribed", kind: "subscription", expiryTS: 1782864000, hasExpiry: true},
125 + }
126 +
127 + for id, want := range expectations {
128 + row, ok := byID[id]
129 + require.Truef(t, ok, "missing license row for %s", id)
130 +
131 + assert.Equal(t, id, row.ID)
132 + assert.Equal(t, want.kind, row.Type, "unexpected license type for %s", id)
133 + require.Truef(t, row.State.Has, "missing state for %s", id)
134 + assert.EqualValues(t, want.severity, row.State.Severity, "unexpected severity for %s", id)
135 + assert.Equal(t, want.state, row.State.Raw, "unexpected raw state for %s", id)
136 + assert.Equal(t, "1.3.6.1.4.1.2604.5.1.5."+licenseIndexForSophosID(id)+".1.0", row.State.SourceOID)
137 +
138 + assert.Equal(t, want.hasExpiry, row.Expiry.Has, "unexpected expiry presence for %s", id)
139 + if want.hasExpiry {
140 + assert.EqualValues(t, want.expiryTS, row.Expiry.Timestamp, "unexpected expiry timestamp for %s", id)
141 + assert.Equal(t, "1.3.6.1.4.1.2604.5.1.5."+licenseIndexForSophosID(id)+".2.0", row.Expiry.SourceOID)
142 + }
143 + }
144 +}
145 +
146 +func licenseIndexForSophosID(id string) string {
147 + return map[string]string{
148 + "base_firewall": "1",
149 + "network_protection": "2",
150 + "web_protection": "3",
151 + "mail_protection": "4",
152 + "web_server_protection": "5",
153 + "sandstorm": "6",
154 + "enhanced_support": "7",
155 + "enhanced_plus_support": "8",
156 + "central_orchestration": "9",
157 + }[id]
158 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table.go
-4
@@ -628,10 +628,6 @@ func (tc *tableCollector) snmpWalk(oid string, stats *ddsnmp.CollectionStats) (m
628 }
629 }
630
631 - if len(pdus) == 0 {
632 - tc.missingOIDs[trimOID(oid)] = true
633 - }
634 -
631 return pdus, nil
632 }
633
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/collector_table_test.go
+1 -1
@@ -224,7 +224,7 @@ func TestTableCollector_Collect(t *testing.T) {
224 },
225 expectedResult: []ddsnmp.Metric{},
226 expectedError: false,
227 - checkMissing: map[string]bool{"1.3.6.1.2.1.2.2": true},
227 + checkMissing: map[string]bool{"1.3.6.1.2.1.2.2": false},
228 },
229 "table walk error": {
230 profile: &ddsnmp.Profile{
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/fixture_loader_test.go new
+272
@@ -0,0 +1,272 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "bufio"
7 + "encoding/hex"
8 + "fmt"
9 + "os"
10 + "strconv"
11 + "strings"
12 + "testing"
13 +
14 + "github.com/gosnmp/gosnmp"
15 + snmpmock "github.com/gosnmp/gosnmp/mocks"
16 +)
17 +
18 +type snmpFixture struct {
19 + entries []gosnmp.SnmpPDU
20 + byOID map[string]gosnmp.SnmpPDU
21 +}
22 +
23 +func mustLoadSNMPFixture(t *testing.T, path string) *snmpFixture {
24 + t.Helper()
25 +
26 + f, err := os.Open(path)
27 + if err != nil {
28 + t.Fatalf("open fixture %s: %v", path, err)
29 + }
30 + defer f.Close()
31 +
32 + fixture := &snmpFixture{byOID: make(map[string]gosnmp.SnmpPDU)}
33 + scanner := bufio.NewScanner(f)
34 + for lineNum := 1; scanner.Scan(); lineNum++ {
35 + pdu, ok, err := parseSNMPFixtureLine(scanner.Text())
36 + if err != nil {
37 + t.Fatalf("parse fixture %s:%d: %v", path, lineNum, err)
38 + }
39 + if !ok {
40 + continue
41 + }
42 +
43 + oid := trimOID(pdu.Name)
44 + pdu.Name = oid
45 + fixture.entries = append(fixture.entries, pdu)
46 + fixture.byOID[oid] = pdu
47 + }
48 + if err := scanner.Err(); err != nil {
49 + t.Fatalf("scan fixture %s: %v", path, err)
50 + }
51 +
52 + return fixture
53 +}
54 +
55 +func parseSNMPFixtureLine(line string) (gosnmp.SnmpPDU, bool, error) {
56 + line = strings.TrimSpace(line)
57 + if line == "" || strings.HasPrefix(line, "#") {
58 + return gosnmp.SnmpPDU{}, false, nil
59 + }
60 +
61 + switch {
62 + case strings.Count(line, "|") >= 2:
63 + return parseSNMPRecLine(line)
64 + case strings.Contains(line, " = "):
65 + return parseSNMPWalkLine(line)
66 + default:
67 + return gosnmp.SnmpPDU{}, false, nil
68 + }
69 +}
70 +
71 +func parseSNMPRecLine(line string) (gosnmp.SnmpPDU, bool, error) {
72 + parts := strings.SplitN(line, "|", 3)
73 + if len(parts) != 3 {
74 + return gosnmp.SnmpPDU{}, false, fmt.Errorf("invalid snmprec line")
75 + }
76 +
77 + oid := trimOID(parts[0])
78 + kind := strings.TrimSpace(parts[1])
79 + raw := strings.TrimSpace(parts[2])
80 +
81 + switch kind {
82 + case "2":
83 + v, err := strconv.ParseInt(raw, 10, 64)
84 + if err != nil {
85 + return gosnmp.SnmpPDU{}, false, err
86 + }
87 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.Integer, Value: int(v)}, true, nil
88 + case "4":
89 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.OctetString, Value: []byte(raw)}, true, nil
90 + case "4x", "64x":
91 + b, err := hex.DecodeString(raw)
92 + if err != nil {
93 + return gosnmp.SnmpPDU{}, false, err
94 + }
95 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.OctetString, Value: b}, true, nil
96 + case "6":
97 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.ObjectIdentifier, Value: raw}, true, nil
98 + case "65":
99 + v, err := strconv.ParseUint(raw, 10, 32)
100 + if err != nil {
101 + return gosnmp.SnmpPDU{}, false, err
102 + }
103 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.Counter32, Value: uint(v)}, true, nil
104 + case "66":
105 + v, err := strconv.ParseUint(raw, 10, 32)
106 + if err != nil {
107 + return gosnmp.SnmpPDU{}, false, err
108 + }
109 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.Gauge32, Value: uint(v)}, true, nil
110 + case "67":
111 + v, err := strconv.ParseUint(raw, 10, 32)
112 + if err != nil {
113 + return gosnmp.SnmpPDU{}, false, err
114 + }
115 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.TimeTicks, Value: uint32(v)}, true, nil
116 + case "70":
117 + v, err := strconv.ParseUint(raw, 10, 64)
118 + if err != nil {
119 + return gosnmp.SnmpPDU{}, false, err
120 + }
121 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.Counter64, Value: v}, true, nil
122 + default:
123 + return gosnmp.SnmpPDU{}, false, fmt.Errorf("unsupported snmprec type %q", kind)
124 + }
125 +}
126 +
127 +func parseSNMPWalkLine(line string) (gosnmp.SnmpPDU, bool, error) {
128 + oid, rest, ok := strings.Cut(line, " = ")
129 + if !ok {
130 + return gosnmp.SnmpPDU{}, false, fmt.Errorf("invalid snmpwalk line")
131 + }
132 + oid = trimOID(strings.TrimSpace(oid))
133 + rest = strings.TrimSpace(rest)
134 +
135 + if rest == `""` {
136 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.OctetString, Value: []byte{}}, true, nil
137 + }
138 +
139 + kind, raw, ok := strings.Cut(rest, ": ")
140 + if !ok {
141 + return gosnmp.SnmpPDU{}, false, fmt.Errorf("unsupported snmpwalk payload %q", rest)
142 + }
143 +
144 + raw = strings.TrimSpace(raw)
145 + switch kind {
146 + case "STRING":
147 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.OctetString, Value: []byte(stripQuotes(raw))}, true, nil
148 + case "Hex-STRING":
149 + b, err := hex.DecodeString(strings.ReplaceAll(raw, " ", ""))
150 + if err != nil {
151 + return gosnmp.SnmpPDU{}, false, err
152 + }
153 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.OctetString, Value: b}, true, nil
154 + case "INTEGER":
155 + v, err := strconv.ParseInt(stripEnumSuffix(raw), 10, 64)
156 + if err != nil {
157 + return gosnmp.SnmpPDU{}, false, err
158 + }
159 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.Integer, Value: int(v)}, true, nil
160 + case "Gauge32":
161 + v, err := strconv.ParseUint(raw, 10, 32)
162 + if err != nil {
163 + return gosnmp.SnmpPDU{}, false, err
164 + }
165 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.Gauge32, Value: uint(v)}, true, nil
166 + case "Counter32":
167 + v, err := strconv.ParseUint(raw, 10, 32)
168 + if err != nil {
169 + return gosnmp.SnmpPDU{}, false, err
170 + }
171 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.Counter32, Value: uint(v)}, true, nil
172 + case "Counter64":
173 + v, err := strconv.ParseUint(raw, 10, 64)
174 + if err != nil {
175 + return gosnmp.SnmpPDU{}, false, err
176 + }
177 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.Counter64, Value: v}, true, nil
178 + case "Timeticks":
179 + v, err := strconv.ParseUint(stripTimeticksValue(raw), 10, 32)
180 + if err != nil {
181 + return gosnmp.SnmpPDU{}, false, err
182 + }
183 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.TimeTicks, Value: uint32(v)}, true, nil
184 + case "OID":
185 + return gosnmp.SnmpPDU{Name: oid, Type: gosnmp.ObjectIdentifier, Value: trimOID(raw)}, true, nil
186 + default:
187 + return gosnmp.SnmpPDU{}, false, fmt.Errorf("unsupported snmpwalk type %q", kind)
188 + }
189 +}
190 +
191 +func stripQuotes(s string) string {
192 + if unquoted, err := strconv.Unquote(s); err == nil {
193 + return unquoted
194 + }
195 + return strings.Trim(s, `"`)
196 +}
197 +
198 +func stripEnumSuffix(s string) string {
199 + s = strings.TrimSpace(s)
200 + if _, err := strconv.ParseInt(s, 10, 64); err == nil {
201 + return s
202 + }
203 +
204 + if close := strings.LastIndex(s, ")"); close == len(s)-1 {
205 + if open := strings.LastIndex(s[:close], "("); open >= 0 {
206 + candidate := strings.TrimSpace(s[open+1 : close])
207 + if _, err := strconv.ParseInt(candidate, 10, 64); err == nil {
208 + return candidate
209 + }
210 + }
211 + }
212 + return s
213 +}
214 +
215 +func stripTimeticksValue(s string) string {
216 + if strings.HasPrefix(s, "(") {
217 + if i := strings.Index(s, ")"); i > 1 {
218 + return s[1:i]
219 + }
220 + }
221 + return stripEnumSuffix(s)
222 +}
223 +
224 +func expectSNMPWalkFromFixture(mockHandler *snmpmock.MockHandler, version gosnmp.SnmpVersion, fixture *snmpFixture, oid string) {
225 + root := trimOID(oid)
226 + prefix := root + "."
227 + pdus := make([]gosnmp.SnmpPDU, 0)
228 + for _, pdu := range fixture.entries {
229 + if pdu.Name == root || strings.HasPrefix(pdu.Name, prefix) {
230 + pdus = append(pdus, pdu)
231 + }
232 + }
233 +
234 + expectSNMPWalk(mockHandler, version, root, pdus)
235 +}
236 +
237 +func mustExpectSNMPGetFromFixture(t *testing.T, mockHandler *snmpmock.MockHandler, fixture *snmpFixture, oids []string) {
238 + t.Helper()
239 +
240 + pdus := make([]gosnmp.SnmpPDU, 0, len(oids))
241 + for _, oid := range oids {
242 + trimmed := trimOID(oid)
243 + pdu, ok := fixture.byOID[trimmed]
244 + if !ok {
245 + t.Fatalf("fixture is missing expected GET OID %s", trimmed)
246 + }
247 + pdus = append(pdus, pdu)
248 + }
249 +
250 + expectSNMPGet(mockHandler, oids, pdus)
251 +}
252 +
253 +func TestParseSNMPWalkLine_IntegerEnumValue(t *testing.T) {
254 + pdu, ok, err := parseSNMPWalkLine(".1.3.6.1.2.1.2.2.1.8.1 = INTEGER: up(1)")
255 + if err != nil {
256 + t.Fatalf("unexpected error: %v", err)
257 + }
258 + if !ok {
259 + t.Fatal("expected fixture line to parse")
260 + }
261 + if pdu.Type != gosnmp.Integer {
262 + t.Fatalf("unexpected PDU type: %v", pdu.Type)
263 + }
264 +
265 + value, ok := pdu.Value.(int)
266 + if !ok {
267 + t.Fatalf("unexpected value type %T", pdu.Value)
268 + }
269 + if value != 1 {
270 + t.Fatalf("unexpected parsed value: got %d, want 1", value)
271 + }
272 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/licensing_test_helpers_test.go new
+59
@@ -0,0 +1,59 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package ddsnmpcollector
4 +
5 +import (
6 + "path/filepath"
7 + "slices"
8 + "strings"
9 + "testing"
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 + "github.com/stretchr/testify/assert"
14 + "github.com/stretchr/testify/require"
15 +)
16 +
17 +func mustLoadTypedLicensingProfile(t *testing.T, profileName string, keep func(row ddprofiledefinition.LicensingConfig) bool) *ddsnmp.Profile {
18 + t.Helper()
19 +
20 + profiles := ddsnmp.DefaultCatalog().Resolve(ddsnmp.ResolveRequest{
21 + ManualProfiles: []string{profileName},
22 + ManualPolicy: ddsnmp.ManualProfileFallback,
23 + }).Project(ddsnmp.ConsumerLicensing).Profiles()
24 + require.Len(t, profiles, 1)
25 +
26 + profile := profiles[0]
27 + profile.Definition.Metadata = nil
28 + profile.Definition.SysobjectIDMetadata = nil
29 + profile.Definition.MetricTags = nil
30 + profile.Definition.StaticTags = nil
31 + profile.Definition.VirtualMetrics = nil
32 + profile.Definition.Topology = nil
33 + profile.Definition.Metrics = nil
34 + profile.Definition.Licensing = slices.DeleteFunc(profile.Definition.Licensing, func(row ddprofiledefinition.LicensingConfig) bool {
35 + return !keep(row)
36 + })
37 +
38 + require.NotEmpty(t, profile.Definition.Licensing)
39 + assert.Equal(t, profileName+".yaml", filepath.Base(profile.SourceFile))
40 +
41 + return profile
42 +}
43 +
44 +func licenseRowsByID(rows []ddsnmp.LicenseRow) map[string]ddsnmp.LicenseRow {
45 + out := make(map[string]ddsnmp.LicenseRow, len(rows))
46 + for _, row := range rows {
47 + out[row.ID] = row
48 + }
49 + return out
50 +}
51 +
52 +func hasLicensingTable(profile *ddsnmp.Profile, oid string) bool {
53 + for _, row := range profile.Definition.Licensing {
54 + if strings.TrimPrefix(row.Table.OID, ".") == oid {
55 + return true
56 + }
57 + }
58 + return false
59 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/table_cache.go
+5
@@ -306,6 +306,11 @@ func (tc *tableCache) isConfigCached(cfg ddprofiledefinition.MetricsConfig) bool
306 func (tc *tableCache) generateConfigID(cfg ddprofiledefinition.MetricsConfig) string {
307 var sb strings.Builder
308
309 + if cfg.MIB != "" {
310 + sb.WriteString(cfg.MIB)
311 + sb.WriteString("|")
312 + }
313 +
314 if cfg.Table.Name != "" {
315 sb.WriteString(cfg.Table.Name)
316 }
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/checkpoint-community.snmpwalk new
+26
@@ -0,0 +1,26 @@
1 +# Adapted from the public Check Point community thread:
2 +# https://community.checkpoint.com/t5/Security-Gateways/Firewall-and-Content-Awareness-Blade-missing-license/td-p/114613
3 +# The forum post shows licensing rows as a rendered table (ID, Blade name, Entitlement status, Expiration date),
4 +# not as a raw snmpwalk. This fixture re-encodes only those public values into the Check Point licensing table
5 +# shape so we can validate real blade names, real state strings, the max-uint32 sentinel, and real epoch values.
6 +# The SNMP row index is not shown in the forum post, so this adapted fixture uses the public IDs as row indexes.
7 +1.3.6.1.4.1.2620.1.6.18.1.1.2.0 = Gauge32: 0
8 +1.3.6.1.4.1.2620.1.6.18.1.1.4.0 = STRING: Firewall
9 +1.3.6.1.4.1.2620.1.6.18.1.1.5.0 = STRING: Not Entitled
10 +1.3.6.1.4.1.2620.1.6.18.1.1.6.0 = Gauge32: 4294967295
11 +1.3.6.1.4.1.2620.1.6.18.1.1.2.4 = Gauge32: 4
12 +1.3.6.1.4.1.2620.1.6.18.1.1.4.4 = STRING: Application Ctrl
13 +1.3.6.1.4.1.2620.1.6.18.1.1.5.4 = STRING: Evaluation
14 +1.3.6.1.4.1.2620.1.6.18.1.1.6.4 = Gauge32: 1619246913
15 +1.3.6.1.4.1.2620.1.6.18.1.1.2.5 = Gauge32: 5
16 +1.3.6.1.4.1.2620.1.6.18.1.1.4.5 = STRING: URL Filtering
17 +1.3.6.1.4.1.2620.1.6.18.1.1.5.5 = STRING: Evaluation
18 +1.3.6.1.4.1.2620.1.6.18.1.1.6.5 = Gauge32: 1619246913
19 +1.3.6.1.4.1.2620.1.6.18.1.1.2.2 = Gauge32: 2
20 +1.3.6.1.4.1.2620.1.6.18.1.1.4.2 = STRING: IPS
21 +1.3.6.1.4.1.2620.1.6.18.1.1.5.2 = STRING: Evaluation
22 +1.3.6.1.4.1.2620.1.6.18.1.1.6.2 = Gauge32: 1619246941
23 +1.3.6.1.4.1.2620.1.6.18.1.1.2.1003 = Gauge32: 1003
24 +1.3.6.1.4.1.2620.1.6.18.1.1.4.1003 = STRING: SmartEvent
25 +1.3.6.1.4.1.2620.1.6.18.1.1.5.1003 = STRING: Evaluation
26 +1.3.6.1.4.1.2620.1.6.18.1.1.6.1003 = Gauge32: 1620542985
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/checkpoint.snmprec new
+24
@@ -0,0 +1,24 @@
1 +# Extracted from public Datadog test data:
2 +# DataDog/integrations-core snmp/tests/compose/data/_checkpoint-firewall-cpu-memory.snmprec
3 +# This public artifact includes the Check Point licensing table shape and value types.
4 +# The string and expiry/quota values look synthetic, so use this fixture for collection/mapping coverage only.
5 +1.3.6.1.4.1.2620.1.6.18.1.1.1.42214|66|42214
6 +1.3.6.1.4.1.2620.1.6.18.1.1.1.50465|66|50465
7 +1.3.6.1.4.1.2620.1.6.18.1.1.2.42214|66|35886
8 +1.3.6.1.4.1.2620.1.6.18.1.1.2.50465|66|20832
9 +1.3.6.1.4.1.2620.1.6.18.1.1.3.42214|66|37422
10 +1.3.6.1.4.1.2620.1.6.18.1.1.3.50465|66|56421
11 +1.3.6.1.4.1.2620.1.6.18.1.1.4.42214|4x|666f72776172642061637465642074686569722061637465642064726976696e6720666f7277617264
12 +1.3.6.1.4.1.2620.1.6.18.1.1.4.50465|4x|717561696e746c7920616374656420666f7277617264207a6f6d62696573206b6570742064726976696e67204a61646564
13 +1.3.6.1.4.1.2620.1.6.18.1.1.5.42214|4x|4a61646564207a6f6d6269657320717561696e746c7920717561696e746c79206f78656e207468656972
14 +1.3.6.1.4.1.2620.1.6.18.1.1.5.50465|4x|666f7277617264206b65707420627574
15 +1.3.6.1.4.1.2620.1.6.18.1.1.6.42214|66|52014
16 +1.3.6.1.4.1.2620.1.6.18.1.1.6.50465|66|36321
17 +1.3.6.1.4.1.2620.1.6.18.1.1.7.42214|4x|64726976696e67206f78656e204a61646564206f78656e207468656972206163746564206163746564207468656972206f78656e
18 +1.3.6.1.4.1.2620.1.6.18.1.1.7.50465|4|driving
19 +1.3.6.1.4.1.2620.1.6.18.1.1.8.42214|2|21
20 +1.3.6.1.4.1.2620.1.6.18.1.1.8.50465|2|8
21 +1.3.6.1.4.1.2620.1.6.18.1.1.9.42214|2|4
22 +1.3.6.1.4.1.2620.1.6.18.1.1.9.50465|2|30
23 +1.3.6.1.4.1.2620.1.6.18.1.1.10.42214|2|15
24 +1.3.6.1.4.1.2620.1.6.18.1.1.10.50465|2|16
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/cisco-smart-iosxe-c9800.snmprec new
+18
@@ -0,0 +1,18 @@
1 +# Extracted from public LibreNMS test data:
2 +# librenms/librenms tests/snmpsim/iosxe_c9800.snmprec
3 +# This public artifact only covers the entitlement table.
4 +# It does not include Smart Licensing scalar registration/auth/certificate timers.
5 +1.3.6.1.4.1.9.9.831.0.5.1.1.2.1|66|55
6 +1.3.6.1.4.1.9.9.831.0.5.1.1.2.2|66|55
7 +1.3.6.1.4.1.9.9.831.0.5.1.1.3.1|4x|444e415f4e57535441434b5f45
8 +1.3.6.1.4.1.9.9.831.0.5.1.1.3.2|4x|4149522d444e412d45
9 +1.3.6.1.4.1.9.9.831.0.5.1.1.4.1|4x|312e30
10 +1.3.6.1.4.1.9.9.831.0.5.1.1.4.2|4x|312e30
11 +1.3.6.1.4.1.9.9.831.0.5.1.1.5.1|2|15
12 +1.3.6.1.4.1.9.9.831.0.5.1.1.5.2|2|15
13 +1.3.6.1.4.1.9.9.831.0.5.1.1.6.1|4x|6169722d6e6574776f726b2d657373656e7469616c73
14 +1.3.6.1.4.1.9.9.831.0.5.1.1.6.2|4x|6169722d646e612d657373656e7469616c73
15 +1.3.6.1.4.1.9.9.831.0.5.1.1.7.1|4x|6169722d6e6574776f726b2d657373656e7469616c73
16 +1.3.6.1.4.1.9.9.831.0.5.1.1.7.2|4x|6169722d646e612d657373656e7469616c73
17 +1.3.6.1.4.1.9.9.831.0.5.1.1.8.1|2|1
18 +1.3.6.1.4.1.9.9.831.0.5.1.1.8.2|2|1
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/cisco-traditional.snmpwalk new
+20
@@ -0,0 +1,20 @@
1 +# Extracted from public OpenNMS test data:
2 +# OpenNMS/opennms opennms-services/src/test/resources/org/opennms/netmgt/snmp/cisco-mempool-snmpwalk.properties
3 +.1.3.6.1.4.1.9.9.543.1.2.3.1.3.1.1.1 = STRING: "ipbasek9"
4 +.1.3.6.1.4.1.9.9.543.1.2.3.1.4.1.1.1 = STRING: "1.0"
5 +.1.3.6.1.4.1.9.9.543.1.2.3.1.5.1.1.1 = INTEGER: 4
6 +.1.3.6.1.4.1.9.9.543.1.2.3.1.8.1.1.1 = Gauge32: 0
7 +.1.3.6.1.4.1.9.9.543.1.2.3.1.10.1.1.1 = Gauge32: 4294967295
8 +.1.3.6.1.4.1.9.9.543.1.2.3.1.11.1.1.1 = Gauge32: 4294967295
9 +.1.3.6.1.4.1.9.9.543.1.2.3.1.13.1.1.1 = ""
10 +.1.3.6.1.4.1.9.9.543.1.2.3.1.14.1.1.1 = INTEGER: 3
11 +.1.3.6.1.4.1.9.9.543.1.2.3.1.16.1.1.1 = ""
12 +.1.3.6.1.4.1.9.9.543.1.2.3.1.3.1.2.8 = STRING: "cme-srst"
13 +.1.3.6.1.4.1.9.9.543.1.2.3.1.4.1.2.8 = STRING: "1.0"
14 +.1.3.6.1.4.1.9.9.543.1.2.3.1.5.1.2.8 = INTEGER: 8
15 +.1.3.6.1.4.1.9.9.543.1.2.3.1.8.1.2.8 = Gauge32: 5184000
16 +.1.3.6.1.4.1.9.9.543.1.2.3.1.10.1.2.8 = Gauge32: 5000
17 +.1.3.6.1.4.1.9.9.543.1.2.3.1.11.1.2.8 = Gauge32: 5000
18 +.1.3.6.1.4.1.9.9.543.1.2.3.1.13.1.2.8 = ""
19 +.1.3.6.1.4.1.9.9.543.1.2.3.1.14.1.2.8 = INTEGER: 2
20 +.1.3.6.1.4.1.9.9.543.1.2.3.1.16.1.2.8 = ""
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/mikrotik-router.snmprec new
+6
@@ -0,0 +1,6 @@
1 +# Extracted from public Datadog test data:
2 +# DataDog/integrations-core snmp/tests/compose/data/mikrotik-router.snmprec
3 +# Source repository license: BSD 3-Clause.
4 +# Keep only the RouterOS licensing OIDs needed by the fixture-backed licensing test.
5 +1.3.6.1.4.1.14988.1.1.4.1.0|4x|64726976696e672064726976696e67206b65707420666f72776172642064726976696e67206f78656e
6 +1.3.6.1.4.1.14988.1.1.4.2.0|4x|07d209150d352003
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/testdata/licensing/sophos-xgs-firewall.snmprec new
+23
@@ -0,0 +1,23 @@
1 +# Extracted from public Datadog test data:
2 +# DataDog/integrations-core snmp/tests/compose/data/sophos-xgs-firewall.snmprec
3 +# The public expiry strings in this capture are anonymized placeholder text, so
4 +# this fixture uses explicit no-value sentinels for expiry fields.
5 +# Use this fixture for collection/mapping coverage only, not for real date parsing trust.
6 +1.3.6.1.4.1.2604.5.1.5.1.1.0|2|5
7 +1.3.6.1.4.1.2604.5.1.5.1.2.0|4|N/A
8 +1.3.6.1.4.1.2604.5.1.5.2.1.0|2|2
9 +1.3.6.1.4.1.2604.5.1.5.2.2.0|4|N/A
10 +1.3.6.1.4.1.2604.5.1.5.3.1.0|2|1
11 +1.3.6.1.4.1.2604.5.1.5.3.2.0|4|N/A
12 +1.3.6.1.4.1.2604.5.1.5.4.1.0|2|3
13 +1.3.6.1.4.1.2604.5.1.5.4.2.0|4|N/A
14 +1.3.6.1.4.1.2604.5.1.5.5.1.0|2|3
15 +1.3.6.1.4.1.2604.5.1.5.5.2.0|4|N/A
16 +1.3.6.1.4.1.2604.5.1.5.6.1.0|2|3
17 +1.3.6.1.4.1.2604.5.1.5.6.2.0|4|N/A
18 +1.3.6.1.4.1.2604.5.1.5.7.1.0|2|2
19 +1.3.6.1.4.1.2604.5.1.5.7.2.0|4|N/A
20 +1.3.6.1.4.1.2604.5.1.5.8.1.0|2|2
21 +1.3.6.1.4.1.2604.5.1.5.8.2.0|4|N/A
22 +1.3.6.1.4.1.2604.5.1.5.9.1.0|2|0
23 +1.3.6.1.4.1.2604.5.1.5.9.2.0|4|N/A
src/go/plugin/go.d/collector/snmp/ddsnmp/ddsnmpcollector/utils.go
+14
@@ -76,6 +76,9 @@ func convPduToStringf(pdu gosnmp.SnmpPDU, format string) (string, error) {
76 }
77 return hex.EncodeToString(bs), nil
78 case "snmp_dateandtime":
79 + if isEmptyOctetStringPDU(pdu) {
80 + return "", errNoTextDateValue
81 + }
82 ts, err := convPduToDateAndTimeUnix(pdu)
83 if err != nil {
84 return "", err
@@ -102,6 +105,17 @@ func convPduToStringf(pdu gosnmp.SnmpPDU, format string) (string, error) {
105 }
106 }
107
108 +func isEmptyOctetStringPDU(pdu gosnmp.SnmpPDU) bool {
109 + switch v := pdu.Value.(type) {
110 + case []byte:
111 + return len(v) == 0
112 + case string:
113 + return v == ""
114 + default:
115 + return false
116 + }
117 +}
118 +
119 func convNumericPduToInt64f(pdu gosnmp.SnmpPDU, format string) (int64, error) {
120 if !isPduNumericType(pdu) {
121 return 0, fmt.Errorf("cannot convert %T to numeric value", pdu.Value)
src/go/plugin/go.d/collector/snmp/ddsnmp/load.go
+31
@@ -163,6 +163,7 @@ func loadProfileWithExtendsMap(filename string, extendsPaths multipath.MultiPath
163 if prof.SourceFile == "" {
164 prof.SourceFile, _ = filepath.Abs(filename)
165 }
166 + setLicensingOriginProfileID(&prof, profileOriginID(filename, extendsPaths))
167
168 // Handle empty profiles - these are profiles where content has been deliberately removed,
169 // but the file itself is preserved. This ensures that when users update, their existing
@@ -210,6 +211,36 @@ func loadProfileWithExtendsMap(filename string, extendsPaths multipath.MultiPath
211 return &prof, nil
212 }
213
214 +func setLicensingOriginProfileID(prof *Profile, originID string) {
215 + if prof == nil || prof.Definition == nil {
216 + return
217 + }
218 + for i := range prof.Definition.Licensing {
219 + if prof.Definition.Licensing[i].OriginProfileID == "" {
220 + prof.Definition.Licensing[i].OriginProfileID = originID
221 + }
222 + }
223 +}
224 +
225 +func profileOriginID(filename string, paths multipath.MultiPath) string {
226 + absFile, err := filepath.Abs(filename)
227 + if err != nil {
228 + absFile = filename
229 + }
230 + for _, dir := range paths {
231 + absDir, err := filepath.Abs(dir)
232 + if err != nil {
233 + continue
234 + }
235 + rel, err := filepath.Rel(absDir, absFile)
236 + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." {
237 + continue
238 + }
239 + return filepath.ToSlash(rel)
240 + }
241 + return filepath.ToSlash(filepath.Base(filename))
242 +}
243 +
244 func prepareLoadedProfile(profile *Profile) error {
245 if err := profile.validate(); err != nil {
246 return err
src/go/plugin/go.d/collector/snmp/ddsnmp/metric.go
+66 -5
@@ -12,6 +12,7 @@ type ProfileMetrics struct {
12 Tags map[string]string
13 Metrics []Metric
14 TopologyMetrics []Metric
15 + LicenseRows []LicenseRow
16 HiddenMetrics []Metric
17 Stats CollectionStats
18 }
@@ -39,6 +40,59 @@ type MetaTag struct {
40 IsExactMatch bool // whether this value is from an exact match context
41 }
42
43 +type LicenseRow struct {
44 + OriginProfileID string
45 + TableOID string
46 + Table string
47 + RowKey string
48 + StructuralID string
49 +
50 + ID string
51 + Name string
52 + Feature string
53 + Component string
54 + Type string
55 + Impact string
56 +
57 + IsPerpetual bool
58 + IsUnlimited bool
59 +
60 + State LicenseState
61 + Expiry LicenseTimer
62 + Authorization LicenseTimer
63 + Certificate LicenseTimer
64 + Grace LicenseTimer
65 + Usage LicenseUsage
66 +
67 + Tags map[string]string
68 +}
69 +
70 +type LicenseState struct {
71 + Has bool
72 + Severity int64
73 + Raw string
74 + Policy ddprofiledefinition.LicenseStatePolicy
75 + SourceOID string
76 +}
77 +
78 +type LicenseTimer struct {
79 + Has bool
80 + Timestamp int64
81 + RemainingSeconds int64
82 + SourceOID string
83 +}
84 +
85 +type LicenseUsage struct {
86 + HasUsed bool
87 + Used int64
88 + HasCapacity bool
89 + Capacity int64
90 + HasAvailable bool
91 + Available int64
92 + HasPercent bool
93 + Percent int64
94 +}
95 +
96 // CollectionStats contains statistics for a single profile collection cycle.
97 type CollectionStats struct {
98 Timing TimingStats
@@ -54,12 +108,14 @@ type TimingStats struct {
108 Scalar time.Duration
109 // Table is time spent collecting table metrics.
110 Table time.Duration
111 + // Licensing is time spent collecting typed licensing rows.
112 + Licensing time.Duration
113 // VirtualMetrics is time spent computing derived/aggregated metrics.
114 VirtualMetrics time.Duration
115 }
116
117 func (s TimingStats) Total() time.Duration {
62 - return s.Scalar + s.Table + s.VirtualMetrics
118 + return s.Scalar + s.Table + s.Licensing + s.VirtualMetrics
119 }
120
121 // SNMPOperationStats captures SNMP protocol-level operations.
@@ -86,9 +142,13 @@ type MetricCountStats struct {
142 Table int64
143 // Virtual is the count of computed/derived metrics.
144 Virtual int64
89 - // Tables is the count of unique tables with metrics.
145 + // Licensing is the count of typed licensing rows produced.
146 + Licensing int64
147 + // Tables is the count of unique regular metric tables. Typed licensing
148 + // rows are counted separately in Licensing.
149 Tables int64
91 - // Rows is the total number of table rows across all tables.
150 + // Rows is the total number of regular metric table rows. Typed licensing
151 + // rows are counted separately in Licensing.
152 Rows int64
153 }
154
@@ -106,8 +166,9 @@ type ErrorStats struct {
166 SNMP int64
167 // Processing is the count of value conversion/transform errors.
168 Processing struct {
109 - Scalar int64
110 - Table int64
169 + Scalar int64
170 + Table int64
171 + Licensing int64
172 }
173 // MissingOIDs is the count of NoSuchObject/NoSuchName responses.
174 MissingOIDs int64
src/go/plugin/go.d/collector/snmp/ddsnmp/profile.go
+74
@@ -177,6 +177,7 @@ func (p *Profile) merge(base *Profile) error {
177 if err := p.mergeTopology(base); err != nil {
178 return err
179 }
180 + p.mergeLicensing(base)
181 // Append other fields as before (these likely don't need deduplication)
182 p.Definition.MetricTags = append(p.Definition.MetricTags, base.Definition.MetricTags...)
183 p.Definition.StaticTags = append(slices.Clone(base.Definition.StaticTags), p.Definition.StaticTags...)
@@ -315,6 +316,20 @@ func (p *Profile) mergeTopology(base *Profile) error {
316 return nil
317 }
318
319 +func (p *Profile) mergeLicensing(base *Profile) {
320 + overridden := make(map[string]bool, len(p.Definition.Licensing))
321 + for _, row := range p.Definition.Licensing {
322 + overridden[ddprofiledefinition.LicenseMergeIdentity(row)] = true
323 + }
324 +
325 + for _, row := range base.Definition.Licensing {
326 + if overridden[ddprofiledefinition.LicenseMergeIdentity(row)] {
327 + continue
328 + }
329 + p.Definition.Licensing = append(p.Definition.Licensing, row)
330 + }
331 +}
332 +
333 func indexTopologyMergeConflicts(
334 topo ddprofiledefinition.TopologyConfig,
335 scalarKinds map[topologyScalarConflictKey]ddprofiledefinition.TopologyKind,
@@ -506,6 +521,7 @@ func deduplicateMetricsAcrossProfiles(profiles []*Profile) {
521 // Just deduplicate metrics, keeping the first occurrence (most specific)
522 seenMetrics := make(map[string]bool)
523 seenVmetrics := make(map[string]bool)
524 + seenLicenseSignals := make(map[string]bool)
525
526 for _, prof := range profiles {
527 if prof.Definition == nil {
@@ -536,6 +552,7 @@ func deduplicateMetricsAcrossProfiles(profiles []*Profile) {
552 )
553
554 deduplicateTopologyInProfile(prof, seenMetrics)
555 + deduplicateLicensingInProfile(prof, seenLicenseSignals)
556 }
557 }
558
@@ -577,6 +594,63 @@ func deduplicateTopologyInProfile(prof *Profile, seenMetrics map[string]bool) {
594 prof.Definition.Topology = filtered
595 }
596
597 +func deduplicateLicensingInProfile(prof *Profile, seenSignals map[string]bool) {
598 + filtered := prof.Definition.Licensing[:0]
599 + for _, row := range prof.Definition.Licensing {
600 + keys := generateLicenseSignalKeys(row)
601 + if len(keys) == 0 {
602 + filtered = append(filtered, row)
603 + continue
604 + }
605 +
606 + duplicate := false
607 + for _, key := range keys {
608 + if seenSignals[key] {
609 + duplicate = true
610 + break
611 + }
612 + }
613 + if duplicate {
614 + continue
615 + }
616 + for _, key := range keys {
617 + seenSignals[key] = true
618 + }
619 + filtered = append(filtered, row)
620 + }
621 + if len(filtered) == 0 {
622 + prof.Definition.Licensing = nil
623 + return
624 + }
625 + prof.Definition.Licensing = filtered
626 +}
627 +
628 +func generateLicenseSignalKeys(row ddprofiledefinition.LicensingConfig) []string {
629 + identity := ddprofiledefinition.LicenseStructuralIdentity(row)
630 + var keys []string
631 + add := func(value ddprofiledefinition.LicenseValueConfig) {
632 + if value.IsSet() && value.Kind != "" {
633 + keys = append(keys, strings.Join([]string{identity, string(value.Kind)}, "|"))
634 + }
635 + }
636 + add(row.State.LicenseValueConfig)
637 + addLicenseTimerSignalKeys(row.Signals.Expiry, add)
638 + addLicenseTimerSignalKeys(row.Signals.Authorization, add)
639 + addLicenseTimerSignalKeys(row.Signals.Certificate, add)
640 + addLicenseTimerSignalKeys(row.Signals.Grace, add)
641 + add(row.Signals.Usage.Used)
642 + add(row.Signals.Usage.Capacity)
643 + add(row.Signals.Usage.Available)
644 + add(row.Signals.Usage.Percent)
645 + return keys
646 +}
647 +
648 +func addLicenseTimerSignalKeys(cfg ddprofiledefinition.LicenseTimerSignalsConfig, add func(ddprofiledefinition.LicenseValueConfig)) {
649 + add(cfg.LicenseValueConfig)
650 + add(cfg.Timestamp)
651 + add(cfg.Remaining)
652 +}
653 +
654 func generateTopologyScalarMetricKey(topo ddprofiledefinition.TopologyConfig) string {
655 return strings.Join([]string{
656 "topology-scalar",
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_catalog.go
+151 -5
@@ -11,8 +11,9 @@ import (
11 type ProfileConsumer = ddprofiledefinition.ProfileConsumer
12
13 const (
14 - ConsumerMetrics = ddprofiledefinition.ConsumerMetrics
15 - ConsumerTopology = ddprofiledefinition.ConsumerTopology
14 + ConsumerMetrics = ddprofiledefinition.ConsumerMetrics
15 + ConsumerTopology = ddprofiledefinition.ConsumerTopology
16 + ConsumerLicensing = ddprofiledefinition.ConsumerLicensing
17 )
18
19 type ManualProfilePolicy int
@@ -82,16 +83,33 @@ func (r *ResolvedProfileSet) Profiles() []*Profile {
83 return r.profiles
84 }
85
85 -func (r *ResolvedProfileSet) Project(consumer ProfileConsumer) ProjectedView {
86 +func (r *ResolvedProfileSet) Project(consumer ProfileConsumer, consumers ...ProfileConsumer) ProjectedView {
87 if r == nil || len(r.profiles) == 0 {
88 return ProjectedView{}
89 }
90
91 + requested := append([]ProfileConsumer{consumer}, consumers...)
92 + if len(requested) > 1 {
93 + return r.project(func(prof *Profile) {
94 + projectProfileForConsumers(prof, requested)
95 + }, func(def *ddprofiledefinition.ProfileDefinition) bool {
96 + return profileHasProjectedDataForConsumers(def, requested)
97 + })
98 + }
99 +
100 + return r.project(func(prof *Profile) {
101 + projectProfile(prof, consumer)
102 + }, func(def *ddprofiledefinition.ProfileDefinition) bool {
103 + return profileHasProjectedData(def, consumer)
104 + })
105 +}
106 +
107 +func (r *ResolvedProfileSet) project(project func(*Profile), keep func(*ddprofiledefinition.ProfileDefinition) bool) ProjectedView {
108 profiles := make([]*Profile, 0, len(r.profiles))
109 for _, prof := range r.profiles {
110 projected := prof.clone()
93 - projectProfile(projected, consumer)
94 - if profileHasProjectedData(projected.Definition, consumer) {
111 + project(projected)
112 + if keep(projected.Definition) {
113 profiles = append(profiles, projected)
114 }
115 }
@@ -178,12 +196,19 @@ func projectProfile(prof *Profile, consumer ProfileConsumer) {
196 switch consumer {
197 case ConsumerMetrics:
198 def.Topology = nil
199 + def.Licensing = nil
200 case ConsumerTopology:
201 def.Metrics = nil
202 + def.Licensing = nil
203 + def.VirtualMetrics = nil
204 + case ConsumerLicensing:
205 + def.Metrics = nil
206 + def.Topology = nil
207 def.VirtualMetrics = nil
208 default:
209 def.Metrics = nil
210 def.Topology = nil
211 + def.Licensing = nil
212 def.VirtualMetrics = nil
213 def.Metadata = nil
214 def.SysobjectIDMetadata = nil
@@ -191,6 +216,28 @@ func projectProfile(prof *Profile, consumer ProfileConsumer) {
216 }
217 }
218
219 +func projectProfileForConsumers(prof *Profile, consumers []ProfileConsumer) {
220 + if prof == nil || prof.Definition == nil {
221 + return
222 + }
223 +
224 + def := prof.Definition
225 + def.Metadata = projectMetadataForConsumers(def.Metadata, consumers)
226 + def.SysobjectIDMetadata = projectSysobjectIDMetadataForConsumers(def.SysobjectIDMetadata, consumers)
227 + def.MetricTags = projectGlobalMetricTagsForConsumers(def.MetricTags, consumers)
228 +
229 + if !profileConsumersInclude(consumers, ConsumerMetrics) {
230 + def.Metrics = nil
231 + def.VirtualMetrics = nil
232 + }
233 + if !profileConsumersInclude(consumers, ConsumerTopology) {
234 + def.Topology = nil
235 + }
236 + if !profileConsumersInclude(consumers, ConsumerLicensing) {
237 + def.Licensing = nil
238 + }
239 +}
240 +
241 func projectMetadata(meta ddprofiledefinition.MetadataConfig, consumer ProfileConsumer) ddprofiledefinition.MetadataConfig {
242 if len(meta) == 0 {
243 return nil
@@ -218,6 +265,33 @@ func projectMetadata(meta ddprofiledefinition.MetadataConfig, consumer ProfileCo
265 return projected
266 }
267
268 +func projectMetadataForConsumers(meta ddprofiledefinition.MetadataConfig, consumers []ProfileConsumer) ddprofiledefinition.MetadataConfig {
269 + if len(meta) == 0 {
270 + return nil
271 + }
272 + projected := make(ddprofiledefinition.MetadataConfig)
273 + for resName, res := range meta {
274 + fields := make(map[string]ddprofiledefinition.MetadataField)
275 + for name, field := range res.Fields {
276 + if consumersIncludeAny(field.Consumers, consumers) {
277 + fields[name] = field
278 + }
279 + }
280 + idTags := projectMetricTagListForConsumers(res.IDTags, consumers)
281 + if len(fields) == 0 && len(idTags) == 0 {
282 + continue
283 + }
284 + projected[resName] = ddprofiledefinition.MetadataResourceConfig{
285 + Fields: fields,
286 + IDTags: idTags,
287 + }
288 + }
289 + if len(projected) == 0 {
290 + return nil
291 + }
292 + return projected
293 +}
294 +
295 func projectSysobjectIDMetadata(entries []ddprofiledefinition.SysobjectIDMetadataEntryConfig, consumer ProfileConsumer) []ddprofiledefinition.SysobjectIDMetadataEntryConfig {
296 if len(entries) == 0 {
297 return nil
@@ -244,6 +318,32 @@ func projectSysobjectIDMetadata(entries []ddprofiledefinition.SysobjectIDMetadat
318 return projected
319 }
320
321 +func projectSysobjectIDMetadataForConsumers(entries []ddprofiledefinition.SysobjectIDMetadataEntryConfig, consumers []ProfileConsumer) []ddprofiledefinition.SysobjectIDMetadataEntryConfig {
322 + if len(entries) == 0 {
323 + return nil
324 + }
325 + projected := make([]ddprofiledefinition.SysobjectIDMetadataEntryConfig, 0, len(entries))
326 + for _, entry := range entries {
327 + fields := make(map[string]ddprofiledefinition.MetadataField)
328 + for name, field := range entry.Metadata {
329 + if consumersIncludeAny(field.Consumers, consumers) {
330 + fields[name] = field
331 + }
332 + }
333 + if len(fields) == 0 {
334 + continue
335 + }
336 + projected = append(projected, ddprofiledefinition.SysobjectIDMetadataEntryConfig{
337 + SysobjectID: entry.SysobjectID,
338 + Metadata: fields,
339 + })
340 + }
341 + if len(projected) == 0 {
342 + return nil
343 + }
344 + return projected
345 +}
346 +
347 func projectMetricTagList(tags []ddprofiledefinition.MetricTagConfig, consumer ProfileConsumer) []ddprofiledefinition.MetricTagConfig {
348 // Metadata id_tags do not carry Consumers today. They inherit metadata defaults.
349 if consumer == ConsumerMetrics || consumer == ConsumerTopology {
@@ -252,6 +352,14 @@ func projectMetricTagList(tags []ddprofiledefinition.MetricTagConfig, consumer P
352 return nil
353 }
354
355 +func projectMetricTagListForConsumers(tags []ddprofiledefinition.MetricTagConfig, consumers []ProfileConsumer) []ddprofiledefinition.MetricTagConfig {
356 + // Metadata id_tags do not carry Consumers today. They inherit metadata defaults.
357 + if profileConsumersInclude(consumers, ConsumerMetrics) || profileConsumersInclude(consumers, ConsumerTopology) {
358 + return tags
359 + }
360 + return nil
361 +}
362 +
363 func projectGlobalMetricTags(tags []ddprofiledefinition.GlobalMetricTagConfig, consumer ProfileConsumer) []ddprofiledefinition.GlobalMetricTagConfig {
364 filtered := tags[:0]
365 for _, tag := range tags {
@@ -265,10 +373,34 @@ func projectGlobalMetricTags(tags []ddprofiledefinition.GlobalMetricTagConfig, c
373 return filtered
374 }
375
376 +func projectGlobalMetricTagsForConsumers(tags []ddprofiledefinition.GlobalMetricTagConfig, consumers []ProfileConsumer) []ddprofiledefinition.GlobalMetricTagConfig {
377 + filtered := tags[:0]
378 + for _, tag := range tags {
379 + if consumersIncludeAny(tag.Consumers, consumers) {
380 + filtered = append(filtered, tag)
381 + }
382 + }
383 + if len(filtered) == 0 {
384 + return nil
385 + }
386 + return filtered
387 +}
388 +
389 func consumersInclude(consumers ddprofiledefinition.ConsumerSet, consumer ProfileConsumer) bool {
390 return len(consumers) == 0 || consumers.Contains(consumer)
391 }
392
393 +func consumersIncludeAny(consumers ddprofiledefinition.ConsumerSet, requested []ProfileConsumer) bool {
394 + if len(consumers) == 0 {
395 + return true
396 + }
397 + return slices.ContainsFunc(requested, consumers.Contains)
398 +}
399 +
400 +func profileConsumersInclude(consumers []ProfileConsumer, want ProfileConsumer) bool {
401 + return slices.Contains(consumers, want)
402 +}
403 +
404 func profileHasProjectedData(def *ddprofiledefinition.ProfileDefinition, consumer ProfileConsumer) bool {
405 if def == nil {
406 return false
@@ -285,7 +417,21 @@ func profileHasProjectedData(def *ddprofiledefinition.ProfileDefinition, consume
417 len(def.MetricTags) > 0 ||
418 len(def.Metadata) > 0 ||
419 len(def.SysobjectIDMetadata) > 0
420 + case ConsumerLicensing:
421 + return len(def.Licensing) > 0 ||
422 + len(def.MetricTags) > 0 ||
423 + len(def.Metadata) > 0 ||
424 + len(def.SysobjectIDMetadata) > 0
425 default:
426 return false
427 }
428 }
429 +
430 +func profileHasProjectedDataForConsumers(def *ddprofiledefinition.ProfileDefinition, consumers []ProfileConsumer) bool {
431 + for _, consumer := range consumers {
432 + if profileHasProjectedData(def, consumer) {
433 + return true
434 + }
435 + }
436 + return false
437 +}
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_catalog_test.go
+81
@@ -64,6 +64,7 @@ func TestResolvedProfileSetProject_SeparatesMetricsAndTopology(t *testing.T) {
64 consumer ProfileConsumer
65 metrics int
66 topology int
67 + licensing int
68 virtual int
69 metadataField string
70 metricTag string
@@ -86,6 +87,13 @@ func TestResolvedProfileSetProject_SeparatesMetricsAndTopology(t *testing.T) {
87 sysobjectID: "sysobjectid_topology_vendor",
88 firstKind: ddprofiledefinition.KindLldpRem,
89 },
90 + "licensing_projection": {
91 + consumer: ConsumerLicensing,
92 + licensing: 1,
93 + metadataField: "license_vendor",
94 + metricTag: "license_model",
95 + sysobjectID: "sysobjectid_license_vendor",
96 + },
97 }
98
99 for name, tc := range tests {
@@ -98,6 +106,7 @@ func TestResolvedProfileSetProject_SeparatesMetricsAndTopology(t *testing.T) {
106 def := profiles[0].Definition
107 require.Len(t, def.Metrics, tc.metrics)
108 require.Len(t, def.Topology, tc.topology)
109 + require.Len(t, def.Licensing, tc.licensing)
110 require.Len(t, def.VirtualMetrics, tc.virtual)
111 require.Len(t, def.Metadata["device"].Fields, 1)
112 assert.Contains(t, def.Metadata["device"].Fields, tc.metadataField)
@@ -112,6 +121,57 @@ func TestResolvedProfileSetProject_SeparatesMetricsAndTopology(t *testing.T) {
121 }
122 }
123
124 +func TestResolvedProfileSetProject_MetricsAndLicensing(t *testing.T) {
125 + resolved := &ResolvedProfileSet{profiles: []*Profile{projectionTestProfile()}}
126 +
127 + profiles := resolved.Project(ConsumerMetrics, ConsumerLicensing).Profiles()
128 +
129 + require.Len(t, profiles, 1)
130 + def := profiles[0].Definition
131 + require.Len(t, def.Metrics, 2)
132 + require.Len(t, def.Licensing, 1)
133 + require.Len(t, def.VirtualMetrics, 1)
134 + require.Empty(t, def.Topology)
135 + assert.Contains(t, def.Metadata["device"].Fields, "vendor")
136 + assert.Contains(t, def.Metadata["device"].Fields, "license_vendor")
137 + assert.NotContains(t, def.Metadata["device"].Fields, "lldp_loc_sys_name")
138 + require.Len(t, def.MetricTags, 2)
139 + assert.Equal(t, "model", def.MetricTags[0].Tag)
140 + assert.Equal(t, "license_model", def.MetricTags[1].Tag)
141 + require.Len(t, def.SysobjectIDMetadata, 1)
142 + assert.Contains(t, def.SysobjectIDMetadata[0].Metadata, "sysobjectid_vendor")
143 + assert.Contains(t, def.SysobjectIDMetadata[0].Metadata, "sysobjectid_license_vendor")
144 + assert.NotContains(t, def.SysobjectIDMetadata[0].Metadata, "sysobjectid_topology_vendor")
145 +}
146 +
147 +func TestResolvedProfileSetProject_UnscopedMetricTagsPropagateToLicensing(t *testing.T) {
148 + resolved := &ResolvedProfileSet{profiles: []*Profile{
149 + {
150 + SourceFile: "licensing.yaml",
151 + Definition: &ddprofiledefinition.ProfileDefinition{
152 + MetricTags: []ddprofiledefinition.GlobalMetricTagConfig{
153 + {MetricTagConfig: ddprofiledefinition.MetricTagConfig{Tag: "device_model"}},
154 + },
155 + Licensing: []ddprofiledefinition.LicensingConfig{
156 + {
157 + ID: "license",
158 + OriginProfileID: "licensing.yaml",
159 + Identity: ddprofiledefinition.LicenseIdentityConfig{
160 + ID: ddprofiledefinition.LicenseValueConfig{Value: "license"},
161 + },
162 + },
163 + },
164 + },
165 + },
166 + }}
167 +
168 + profiles := resolved.Project(ConsumerLicensing).Profiles()
169 +
170 + require.Len(t, profiles, 1)
171 + require.Len(t, profiles[0].Definition.MetricTags, 1)
172 + assert.Equal(t, "device_model", profiles[0].Definition.MetricTags[0].Tag)
173 +}
174 +
175 func TestResolvedProfileSetProject_DoesNotShareMutableProjectionState(t *testing.T) {
176 resolved := &ResolvedProfileSet{profiles: []*Profile{projectionTestProfile()}}
177
@@ -164,6 +224,10 @@ func projectionTestProfile() *Profile {
224 Symbol: ddprofiledefinition.SymbolConfig{Name: "lldpLocSysName"},
225 Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerTopology},
226 },
227 + "license_vendor": {
228 + Value: "Cisco licensing",
229 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerLicensing},
230 + },
231 },
232 },
233 },
@@ -179,6 +243,10 @@ func projectionTestProfile() *Profile {
243 Value: "Cisco topology",
244 Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerTopology},
245 },
246 + "sysobjectid_license_vendor": {
247 + Value: "Cisco licensing",
248 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerLicensing},
249 + },
250 },
251 },
252 },
@@ -191,6 +259,10 @@ func projectionTestProfile() *Profile {
259 MetricTagConfig: ddprofiledefinition.MetricTagConfig{Tag: "lldp_loc_chassis_id"},
260 Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerTopology},
261 },
262 + {
263 + MetricTagConfig: ddprofiledefinition.MetricTagConfig{Tag: "license_model"},
264 + Consumers: ddprofiledefinition.ConsumerSet{ddprofiledefinition.ConsumerLicensing},
265 + },
266 },
267 Metrics: []ddprofiledefinition.MetricsConfig{
268 {
@@ -226,6 +298,15 @@ func projectionTestProfile() *Profile {
298 },
299 },
300 },
301 + Licensing: []ddprofiledefinition.LicensingConfig{
302 + {
303 + ID: "smart",
304 + OriginProfileID: "_cisco-licensing-smart.yaml",
305 + Identity: ddprofiledefinition.LicenseIdentityConfig{
306 + ID: ddprofiledefinition.LicenseValueConfig{Value: "smart"},
307 + },
308 + },
309 + },
310 VirtualMetrics: []ddprofiledefinition.VirtualMetricConfig{
311 {Name: "sysNameTotal", Sources: []ddprofiledefinition.VirtualMetricSourceConfig{{Metric: "sysName"}}},
312 },
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_merge_test.go
+72
@@ -33,6 +33,78 @@ func TestProfile_MultipleExtends_TableSymbolLaterOverrideEarlierByNameWithinTabl
33 assert.Equal(t, "base2", sym.ChartMeta.Description)
34 }
35
36 +func TestProfile_MergeLicensingPreservesOriginProfileID(t *testing.T) {
37 + tmp := t.TempDir()
38 +
39 + writeYAML(t, filepath.Join(tmp, "_licensing.yaml"), ddprofiledefinition.ProfileDefinition{
40 + Licensing: []ddprofiledefinition.LicensingConfig{
41 + {
42 + ID: "base-license",
43 + Identity: ddprofiledefinition.LicenseIdentityConfig{
44 + ID: ddprofiledefinition.LicenseValueConfig{Value: "base"},
45 + },
46 + },
47 + },
48 + })
49 + writeYAML(t, filepath.Join(tmp, "device.yaml"), ddprofiledefinition.ProfileDefinition{
50 + Extends: []string{"_licensing.yaml"},
51 + Licensing: []ddprofiledefinition.LicensingConfig{
52 + {
53 + ID: "device-license",
54 + Identity: ddprofiledefinition.LicenseIdentityConfig{
55 + ID: ddprofiledefinition.LicenseValueConfig{Value: "device"},
56 + },
57 + },
58 + },
59 + })
60 +
61 + prof, err := loadProfile(filepath.Join(tmp, "device.yaml"), multipath.New(tmp))
62 + require.NoError(t, err)
63 + require.Len(t, prof.Definition.Licensing, 2)
64 +
65 + assert.Equal(t, "device.yaml", prof.Definition.Licensing[0].OriginProfileID)
66 + assert.Equal(t, "_licensing.yaml", prof.Definition.Licensing[1].OriginProfileID)
67 +}
68 +
69 +func TestProfile_MergeLicensingDerivedRowOverridesBaseIdentity(t *testing.T) {
70 + tmp := t.TempDir()
71 +
72 + baseState := ddprofiledefinition.LicenseStateConfig{
73 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
74 + Symbol: ddprofiledefinition.SymbolConfig{OID: "1.2.3.0", Name: "baseState"},
75 + },
76 + }
77 + derivedState := ddprofiledefinition.LicenseStateConfig{
78 + LicenseValueConfig: ddprofiledefinition.LicenseValueConfig{
79 + Symbol: ddprofiledefinition.SymbolConfig{OID: "1.2.4.0", Name: "derivedState"},
80 + },
81 + }
82 +
83 + writeYAML(t, filepath.Join(tmp, "_licensing.yaml"), ddprofiledefinition.ProfileDefinition{
84 + Licensing: []ddprofiledefinition.LicensingConfig{
85 + {
86 + ID: "smart",
87 + State: baseState,
88 + },
89 + },
90 + })
91 + writeYAML(t, filepath.Join(tmp, "device.yaml"), ddprofiledefinition.ProfileDefinition{
92 + Extends: []string{"_licensing.yaml"},
93 + Licensing: []ddprofiledefinition.LicensingConfig{
94 + {
95 + ID: "smart",
96 + State: derivedState,
97 + },
98 + },
99 + })
100 +
101 + prof, err := loadProfile(filepath.Join(tmp, "device.yaml"), multipath.New(tmp))
102 + require.NoError(t, err)
103 + require.Len(t, prof.Definition.Licensing, 1)
104 + assert.Equal(t, "device.yaml", prof.Definition.Licensing[0].OriginProfileID)
105 + assert.Equal(t, "derivedState", prof.Definition.Licensing[0].State.Symbol.Name)
106 +}
107 +
108 func TestProfile_MultipleExtends_TableSymbolLaterOverrideEarlierByTableNameWhenOIDDiffers(t *testing.T) {
109 tmp := t.TempDir()
110
src/go/plugin/go.d/collector/snmp/ddsnmp/profile_test.go
+50 -8
@@ -177,15 +177,19 @@ func Test_FindProfiles(t *testing.T) {
177 func TestDefaultCatalogResolveProject_LoadedCiscoProfileSeparatesConsumers(t *testing.T) {
178 tests := map[string]struct {
179 consumer ProfileConsumer
180 + consumers []ProfileConsumer
181 wantMetrics []string
182 wantTopologyKinds []ddprofiledefinition.TopologyKind
183 + wantLicensingIDs []string
184 wantNoMetrics bool
185 wantNoTopology bool
186 + wantNoLicensing bool
187 }{
188 "metrics_projection": {
186 - consumer: ConsumerMetrics,
187 - wantMetrics: []string{"systemUptime", "tcpCurrEstab", "cpmCPUTotal5minRev"},
188 - wantNoTopology: true,
189 + consumer: ConsumerMetrics,
190 + wantMetrics: []string{"systemUptime", "tcpCurrEstab", "cpmCPUTotal5minRev"},
191 + wantNoTopology: true,
192 + wantNoLicensing: true,
193 },
194 "topology_projection": {
195 consumer: ConsumerTopology,
@@ -197,7 +201,35 @@ func TestDefaultCatalogResolveProject_LoadedCiscoProfileSeparatesConsumers(t *te
201 ddprofiledefinition.KindStpPort,
202 ddprofiledefinition.KindVtpVlan,
203 },
200 - wantNoMetrics: true,
204 + wantNoMetrics: true,
205 + wantNoLicensing: true,
206 + },
207 + "licensing_projection": {
208 + consumer: ConsumerLicensing,
209 + wantLicensingIDs: []string{
210 + "cisco_traditional_licenses",
211 + "smart_registration",
212 + "smart_authorization",
213 + "smart_id_certificate",
214 + "smart_evaluation_period",
215 + "smart_entitlements",
216 + },
217 + wantNoMetrics: true,
218 + wantNoTopology: true,
219 + },
220 + "metrics_and_licensing_projection": {
221 + consumer: ConsumerMetrics,
222 + consumers: []ProfileConsumer{ConsumerLicensing},
223 + wantMetrics: []string{"systemUptime", "tcpCurrEstab", "cpmCPUTotal5minRev"},
224 + wantLicensingIDs: []string{
225 + "cisco_traditional_licenses",
226 + "smart_registration",
227 + "smart_authorization",
228 + "smart_id_certificate",
229 + "smart_evaluation_period",
230 + "smart_entitlements",
231 + },
232 + wantNoTopology: true,
233 },
234 }
235
@@ -206,11 +238,12 @@ func TestDefaultCatalogResolveProject_LoadedCiscoProfileSeparatesConsumers(t *te
238 profiles := DefaultCatalog().Resolve(ResolveRequest{
239 SysObjectID: "1.3.6.1.4.1.9.1.1",
240 ManualPolicy: ManualProfileFallback,
209 - }).Project(tc.consumer).Profiles()
241 + }).Project(tc.consumer, tc.consumers...).Profiles()
242 require.NotEmpty(t, profiles)
243
244 metricNames := make(map[string]bool)
245 topologyKinds := make(map[ddprofiledefinition.TopologyKind]bool)
246 + licensingIDs := make(map[string]bool)
247
248 for _, prof := range profiles {
249 require.NotNil(t, prof.Definition)
@@ -221,6 +254,9 @@ func TestDefaultCatalogResolveProject_LoadedCiscoProfileSeparatesConsumers(t *te
254 if tc.wantNoTopology {
255 assert.Empty(t, prof.Definition.Topology, prof.SourceFile)
256 }
257 + if tc.wantNoLicensing {
258 + assert.Empty(t, prof.Definition.Licensing, prof.SourceFile)
259 + }
260
261 for _, metric := range prof.Definition.Metrics {
262 if metric.Symbol.Name != "" {
@@ -233,6 +269,9 @@ func TestDefaultCatalogResolveProject_LoadedCiscoProfileSeparatesConsumers(t *te
269 for _, topo := range prof.Definition.Topology {
270 topologyKinds[topo.Kind] = true
271 }
272 + for _, row := range prof.Definition.Licensing {
273 + licensingIDs[row.ID] = true
274 + }
275 }
276
277 for _, metricName := range tc.wantMetrics {
@@ -241,6 +280,9 @@ func TestDefaultCatalogResolveProject_LoadedCiscoProfileSeparatesConsumers(t *te
280 for _, kind := range tc.wantTopologyKinds {
281 assert.True(t, topologyKinds[kind], "missing topology kind %q", kind)
282 }
283 + for _, id := range tc.wantLicensingIDs {
284 + assert.True(t, licensingIDs[id], "missing licensing row %q", id)
285 + }
286 })
287 }
288 }
@@ -273,14 +315,14 @@ func TestProfileMerge_ColumnSymbolsWithSameNameFromBaseArePreserved(t *testing.T
315 {
316 Table: ddprofiledefinition.SymbolConfig{OID: "1.2.3", Name: "tableA"},
317 Symbols: []ddprofiledefinition.SymbolConfig{
276 - {OID: "1.2.3.1", Name: "_license_row"},
277 - {OID: "1.2.3.2", Name: "_license_row"},
318 + {OID: "1.2.3.1", Name: "duplicated.column"},
319 + {OID: "1.2.3.2", Name: "duplicated.column"},
320 },
321 },
322 {
323 Table: ddprofiledefinition.SymbolConfig{OID: "1.2.4", Name: "tableB"},
324 Symbols: []ddprofiledefinition.SymbolConfig{
283 - {OID: "1.2.4.1", Name: "_license_row"},
325 + {OID: "1.2.4.1", Name: "duplicated.column"},
326 },
327 },
328 },
src/go/plugin/go.d/collector/snmp/ddsnmp/transform.go
+4 -37
@@ -323,30 +323,6 @@ func newMetricTransformFuncMap() template.FuncMap {
323
324 return ""
325 },
326 - "licenseDateFromTag": func(m *Metric, tagName, kind string) (string, error) {
327 - // licenseDateFromTag parses a vendor date string carried in a metric tag,
328 - // replaces the metric value with its unix epoch, and stamps the licensing
329 - // value kind. It is intentionally limited to timestamp value kinds; other
330 - // licensing row kinds can use the generic setTag transform directly.
331 - if !isLicenseDateValueKind(kind) {
332 - return "", fmt.Errorf("licenseDateFromTag: unsupported value kind %q", kind)
333 - }
334 - if m.Tags == nil {
335 - return "", nil
336 - }
337 - raw := strings.TrimSpace(m.Tags[tagName])
338 - if raw == "" {
339 - return "", nil
340 - }
341 -
342 - ts, ok := parseTextDate(raw)
343 - if !ok {
344 - return "", nil
345 - }
346 - m.Value = ts
347 - m.Tags["_license_value_kind"] = kind
348 - return "", nil
349 - },
326 }
327
328 maps.Copy(fm, extra)
@@ -355,10 +331,10 @@ func newMetricTransformFuncMap() template.FuncMap {
331 }
332
333 // textDateLayouts is the set of vendor-friendly date formats accepted by
358 -// text_date and licenseDateFromTag. The list is intentionally generous:
359 -// vendors that publish operational dates through SNMP rarely agree on a single
360 -// textual format. Numeric slash-only dates are intentionally excluded because
361 -// dd/mm/yyyy and mm/dd/yyyy are ambiguous for values like 01/02/2024.
334 +// text_date. The list is intentionally generous: vendors that publish
335 +// operational dates through SNMP rarely agree on a single textual format.
336 +// Numeric slash-only dates are intentionally excluded because dd/mm/yyyy and
337 +// mm/dd/yyyy are ambiguous for values like 01/02/2024.
338 var textDateLayouts = []string{
339 time.RFC3339,
340 "2006-01-02 15:04:05",
@@ -393,15 +369,6 @@ func IsTextDateNoValue(raw string) bool {
369 return isTextDateNoValue(raw)
370 }
371
396 -func isLicenseDateValueKind(kind string) bool {
397 - switch kind {
398 - case "expiry_timestamp", "authorization_timestamp", "certificate_timestamp", "grace_timestamp":
399 - return true
400 - default:
401 - return false
402 - }
403 -}
404 -
372 func parseTextDate(raw string) (int64, bool) {
373 raw = strings.TrimSpace(raw)
374 if isTextDateNoValue(raw) {
src/go/plugin/go.d/collector/snmp/ddsnmp/transform_license_test.go
+51 -94
@@ -10,16 +10,16 @@ import (
10 "github.com/stretchr/testify/require"
11 )
12
13 -// runLicenseTransform compiles a transform body and applies it to the given
13 +// runTransform compiles a transform body and applies it to the given
14 // metric. It mirrors the minimal "execute the template against {Metric: m}"
15 // contract used by ddsnmpcollector.applyTransform, without crossing package
16 // boundaries just for the test.
17 -func runLicenseTransform(t *testing.T, body string, m *Metric) {
17 +func runTransform(t *testing.T, body string, m *Metric) {
18 t.Helper()
19 - require.NoError(t, executeLicenseTransform(body, m))
19 + require.NoError(t, executeTransform(body, m))
20 }
21
22 -func executeLicenseTransform(body string, m *Metric) error {
22 +func executeTransform(body string, m *Metric) error {
23 tmpl, err := compileTransform(body)
24 if err != nil {
25 return err
@@ -28,104 +28,61 @@ func executeLicenseTransform(body string, m *Metric) error {
28 return tmpl.Execute(&buf, struct{ Metric *Metric }{Metric: m})
29 }
30
31 -func TestSetTagTransform_StampsValueKindOnTagsMap(t *testing.T) {
32 - m := &Metric{Value: 42, Tags: map[string]string{}}
33 - runLicenseTransform(t, `{{- setTag .Metric "_license_value_kind" "expiry_timestamp" -}}`, m)
34 -
35 - assert.Equal(t, "expiry_timestamp", m.Tags["_license_value_kind"])
36 - assert.EqualValues(t, 42, m.Value)
37 -}
38 -
39 -func TestSetTagTransform_AllocatesTagsWhenNil(t *testing.T) {
40 - m := &Metric{Value: 1}
41 - runLicenseTransform(t, `{{- setTag .Metric "_license_value_kind" "state_severity" -}}`, m)
42 -
43 - require.NotNil(t, m.Tags)
44 - assert.Equal(t, "state_severity", m.Tags["_license_value_kind"])
45 -}
46 -
47 -func TestLicenseDateFromTagTransform_ParsesISODate(t *testing.T) {
48 - m := &Metric{
49 - Value: 0,
50 - Tags: map[string]string{"_license_expiry_text": "2026-12-31"},
51 - }
52 - runLicenseTransform(t, `{{- licenseDateFromTag .Metric "_license_expiry_text" "expiry_timestamp" -}}`, m)
53 -
54 - assert.Equal(t, "expiry_timestamp", m.Tags["_license_value_kind"])
55 - // 2026-12-31 00:00:00 UTC
56 - assert.EqualValues(t, 1798675200, m.Value)
57 -}
58 -
59 -func TestLicenseDateFromTagTransform_ParsesEpochSeconds(t *testing.T) {
60 - m := &Metric{Value: 0, Tags: map[string]string{"x": "1798675200"}}
61 - runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
62 - assert.EqualValues(t, 1798675200, m.Value)
63 -}
64 -
65 -func TestLicenseDateFromTagTransform_ParsesEpochMillis(t *testing.T) {
66 - m := &Metric{Value: 0, Tags: map[string]string{"x": "1798675200000"}}
67 - runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
68 - assert.EqualValues(t, 1798675200, m.Value)
69 -}
70 -
71 -func TestLicenseDateFromTagTransform_ParsesTwelveDigitEpochMillis(t *testing.T) {
72 - m := &Metric{Value: 0, Tags: map[string]string{"x": "946684800000"}}
73 - runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
74 - assert.EqualValues(t, 946684800, m.Value)
75 -}
76 -
77 -func TestLicenseDateFromTagTransform_ParsesCheckpointShortDate(t *testing.T) {
78 - // Checkpoint sends licensingExpirationDate as "2Jan2030", "1Jan2030", etc.
79 - m := &Metric{Value: 0, Tags: map[string]string{"x": "1Jan2030"}}
80 - runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
81 - assert.NotZero(t, m.Value)
82 -}
83 -
84 -func TestLicenseDateFromTagTransform_RejectsAmbiguousSlashDate(t *testing.T) {
85 - m := &Metric{Value: 999, Tags: map[string]string{"x": "01/02/2024"}}
86 - runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
87 -
88 - assert.Empty(t, m.Tags["_license_value_kind"])
89 - assert.EqualValues(t, 999, m.Value)
90 -}
91 -
92 -func TestLicenseDateFromTagTransform_RejectsSentinels(t *testing.T) {
93 - cases := []string{"0", "never", "perpetual", "n/a", "4294967295", ""}
94 - for _, raw := range cases {
95 - m := &Metric{Value: 999, Tags: map[string]string{"x": raw}}
96 - runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
97 - // Untouched: no value_kind stamp, original value preserved.
98 - assert.Empty(t, m.Tags["_license_value_kind"], "raw=%q", raw)
99 - assert.EqualValues(t, 999, m.Value, "raw=%q", raw)
31 +func TestSetTagTransform(t *testing.T) {
32 + tests := map[string]struct {
33 + metric Metric
34 + body string
35 + want string
36 + }{
37 + "stamps value on existing tags map": {
38 + metric: Metric{Value: 42, Tags: map[string]string{}},
39 + body: `{{- setTag .Metric "custom_kind" "expiry_timestamp" -}}`,
40 + want: "expiry_timestamp",
41 + },
42 + "allocates tags when nil": {
43 + metric: Metric{Value: 1},
44 + body: `{{- setTag .Metric "custom_kind" "state_severity" -}}`,
45 + want: "state_severity",
46 + },
47 }
101 -}
48
103 -func TestLicenseDateFromTagTransform_RejectsUnsupportedKind(t *testing.T) {
104 - for _, kind := range []string{"usage", "expiry_remaining", "not_a_kind"} {
105 - m := &Metric{Value: 999, Tags: map[string]string{"x": "2026-12-31"}}
106 - err := executeLicenseTransform(`{{- licenseDateFromTag .Metric "x" "`+kind+`" -}}`, m)
49 + for name, tc := range tests {
50 + t.Run(name, func(t *testing.T) {
51 + m := tc.metric
52 + runTransform(t, tc.body, &m)
53
108 - require.Error(t, err, "kind=%q", kind)
109 - assert.Contains(t, err.Error(), `licenseDateFromTag: unsupported value kind`, "kind=%q", kind)
110 - assert.Empty(t, m.Tags["_license_value_kind"], "kind=%q", kind)
111 - assert.EqualValues(t, 999, m.Value, "kind=%q", kind)
54 + require.NotNil(t, m.Tags)
55 + assert.Equal(t, tc.want, m.Tags["custom_kind"])
56 + assert.EqualValues(t, tc.metric.Value, m.Value)
57 + })
58 }
59 }
60
61 func TestIsTextDateNoValue(t *testing.T) {
116 - noValues := []string{"", "0", "-1", "never", "perpetual", "permanent", "n/a", "na", "none", "unlimited", "4294967295"}
117 - for _, raw := range noValues {
118 - assert.True(t, IsTextDateNoValue(raw), "raw=%q", raw)
62 + tests := map[string]struct {
63 + raw string
64 + want bool
65 + }{
66 + "empty": {raw: "", want: true},
67 + "zero": {raw: "0", want: true},
68 + "negative one": {raw: "-1", want: true},
69 + "never": {raw: "never", want: true},
70 + "perpetual": {raw: "perpetual", want: true},
71 + "permanent": {raw: "permanent", want: true},
72 + "n/a": {raw: "n/a", want: true},
73 + "na": {raw: "na", want: true},
74 + "none": {raw: "none", want: true},
75 + "unlimited": {raw: "unlimited", want: true},
76 + "uint32 max": {raw: "4294967295", want: true},
77 + "one": {raw: "1", want: false},
78 + "unix timestamp": {raw: "1798675200", want: false},
79 + "text date": {raw: "2026-12-31", want: false},
80 + "invalid nonzero": {raw: "not-a-date", want: false},
81 }
82
121 - values := []string{"1", "1798675200", "2026-12-31", "not-a-date"}
122 - for _, raw := range values {
123 - assert.False(t, IsTextDateNoValue(raw), "raw=%q", raw)
83 + for name, tc := range tests {
84 + t.Run(name, func(t *testing.T) {
85 + assert.Equal(t, tc.want, IsTextDateNoValue(tc.raw))
86 + })
87 }
88 }
126 -
127 -func TestLicenseDateFromTagTransform_NoTagsMapIsNoop(t *testing.T) {
128 - m := &Metric{Value: 7}
129 - runLicenseTransform(t, `{{- licenseDateFromTag .Metric "x" "expiry_timestamp" -}}`, m)
130 - assert.EqualValues(t, 7, m.Value)
131 -}
src/go/plugin/go.d/collector/snmp/func_interfaces_test.go
+3 -1
@@ -20,10 +20,12 @@ func newTestFuncInterfaces(cache *ifaceCache) *funcInterfaces {
20 func TestSnmpMethods(t *testing.T) {
21 methods := snmpMethods()
22
23 - require.Len(t, methods, 1)
23 + require.Len(t, methods, 2)
24 assert.Equal(t, "interfaces", methods[0].ID)
25 assert.Equal(t, "Network Interfaces", methods[0].Name)
26 require.NotEmpty(t, methods[0].RequiredParams)
27 + assert.Equal(t, "licenses", methods[1].ID)
28 + assert.Equal(t, "Licenses", methods[1].Name)
29
30 // Verify type group param exists
31 var typeGroupParam *funcapi.ParamConfig
src/go/plugin/go.d/collector/snmp/func_licenses.go new
+260
@@ -0,0 +1,260 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "context"
7 + "sort"
8 + "strconv"
9 + "strings"
10 + "time"
11 +
12 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
13 +)
14 +
15 +const licensesMethodID = "licenses"
16 +
17 +func licensesMethodConfig() funcapi.MethodConfig {
18 + return funcapi.MethodConfig{
19 + ID: licensesMethodID,
20 + Name: "Licenses",
21 + UpdateEvery: 10,
22 + Help: "Normalized SNMP licensing rows for the selected device",
23 + // Licenses is intentionally parameterless: it returns this job's cached rows.
24 + RequiredParams: []funcapi.ParamConfig{},
25 + }
26 +}
27 +
28 +// Compile-time interface check.
29 +var _ funcapi.MethodHandler = (*funcLicenses)(nil)
30 +
31 +type funcLicenses struct {
32 + cache *licenseCache
33 +}
34 +
35 +func newFuncLicenses(cache *licenseCache) *funcLicenses {
36 + return &funcLicenses{cache: cache}
37 +}
38 +
39 +func (f *funcLicenses) MethodParams(_ context.Context, method string) ([]funcapi.ParamConfig, error) {
40 + if method != licensesMethodID {
41 + return nil, nil
42 + }
43 + return []funcapi.ParamConfig{}, nil
44 +}
45 +
46 +func (f *funcLicenses) Cleanup(_ context.Context) {}
47 +
48 +func (f *funcLicenses) Handle(_ context.Context, method string, _ funcapi.ResolvedParams) *funcapi.FunctionResponse {
49 + if method != licensesMethodID {
50 + return funcapi.NotFoundResponse(method)
51 + }
52 +
53 + if f.cache == nil {
54 + return funcapi.UnavailableResponse("license data not available yet, please retry after data collection")
55 + }
56 +
57 + lastUpdate, rows := f.cache.snapshot()
58 + if lastUpdate.IsZero() {
59 + return funcapi.UnavailableResponse("license data not available yet, please retry after data collection")
60 + }
61 + if len(rows) == 0 {
62 + return funcapi.UnavailableResponse("license data is not available for this device")
63 + }
64 +
65 + sortLicenseRows(rows)
66 + now := time.Now().UTC()
67 + cs := licenseColumnSet(licenseAllColumns)
68 + data := make([][]any, 0, len(rows))
69 + for _, row := range rows {
70 + data = append(data, buildLicenseFunctionRow(row, now))
71 + }
72 +
73 + return &funcapi.FunctionResponse{
74 + Status: 200,
75 + Help: "Normalized SNMP licensing rows for the selected device",
76 + Columns: buildLicenseColumns(cs),
77 + Data: data,
78 + DefaultSortColumn: defaultLicenseSortColumn(),
79 + }
80 +}
81 +
82 +type licenseColumn struct {
83 + funcapi.ColumnMeta
84 + Value func(licenseRow, time.Time) any
85 + DefaultSort bool
86 +}
87 +
88 +func licenseColumnSet(cols []licenseColumn) funcapi.ColumnSet[licenseColumn] {
89 + return funcapi.Columns(cols, func(c licenseColumn) funcapi.ColumnMeta { return c.ColumnMeta })
90 +}
91 +
92 +var licenseAllColumns = []licenseColumn{
93 + {ColumnMeta: funcapi.ColumnMeta{Name: "License", Tooltip: "License", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sticky: true, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return licenseDisplayValue(r) }, DefaultSort: true},
94 + {ColumnMeta: funcapi.ColumnMeta{Name: "Bucket", Tooltip: "Normalized State Bucket", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true, Visualization: funcapi.FieldVisualPill}, Value: func(r licenseRow, _ time.Time) any { return string(r.StateBucket) }},
95 + {ColumnMeta: funcapi.ColumnMeta{Name: "State", Tooltip: "Raw vendor state", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return emptyToNil(r.StateRaw) }},
96 + {ColumnMeta: funcapi.ColumnMeta{Name: "Source", Tooltip: "Profile source", Type: funcapi.FieldTypeString, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return emptyToNil(r.Source) }},
97 + {ColumnMeta: funcapi.ColumnMeta{Name: "ID", Tooltip: "Stable row identifier", Type: funcapi.FieldTypeString, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true, UniqueKey: true}, Value: func(r licenseRow, _ time.Time) any { return licenseRowUniqueKey(r) }},
98 + {ColumnMeta: funcapi.ColumnMeta{Name: "Feature", Tooltip: "Feature name", Type: funcapi.FieldTypeString, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return emptyToNil(r.Feature) }},
99 + {ColumnMeta: funcapi.ColumnMeta{Name: "Component", Tooltip: "License component", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return emptyToNil(r.Component) }},
100 + {ColumnMeta: funcapi.ColumnMeta{Name: "Type", Tooltip: "License type", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return emptyToNil(r.Type) }},
101 + {ColumnMeta: funcapi.ColumnMeta{Name: "Remaining", Tooltip: "Time remaining until expiry", Type: funcapi.FieldTypeDuration, Units: "milliseconds", Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Sortable: true, Transform: funcapi.FieldTransformDuration}, Value: func(r licenseRow, ts time.Time) any {
102 + return licenseRemainingCell(r.ExpiryTS, r.HasExpiry && !r.IsPerpetual, ts)
103 + }},
104 + {ColumnMeta: funcapi.ColumnMeta{Name: "Expiry", Tooltip: "Expiry time", Type: funcapi.FieldTypeTimestamp, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Sortable: true, Transform: funcapi.FieldTransformDatetime}, Value: func(r licenseRow, _ time.Time) any {
105 + return licenseTimestampCell(r.ExpiryTS, r.HasExpiry && !r.IsPerpetual)
106 + }},
107 + {ColumnMeta: funcapi.ColumnMeta{Name: "Auth Remaining", Tooltip: "Authorization time remaining", Type: funcapi.FieldTypeDuration, Units: "milliseconds", Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Sortable: true, Transform: funcapi.FieldTransformDuration}, Value: func(r licenseRow, ts time.Time) any {
108 + return licenseRemainingCell(r.AuthorizationExpiry, r.HasAuthorizationTime, ts)
109 + }},
110 + {ColumnMeta: funcapi.ColumnMeta{Name: "Auth Expiry", Tooltip: "Authorization expiry time", Type: funcapi.FieldTypeTimestamp, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Sortable: true, Transform: funcapi.FieldTransformDatetime}, Value: func(r licenseRow, _ time.Time) any {
111 + return licenseTimestampCell(r.AuthorizationExpiry, r.HasAuthorizationTime)
112 + }},
113 + {ColumnMeta: funcapi.ColumnMeta{Name: "Cert Remaining", Tooltip: "Certificate time remaining", Type: funcapi.FieldTypeDuration, Units: "milliseconds", Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Sortable: true, Transform: funcapi.FieldTransformDuration}, Value: func(r licenseRow, ts time.Time) any {
114 + return licenseRemainingCell(r.CertificateExpiry, r.HasCertificateTime, ts)
115 + }},
116 + {ColumnMeta: funcapi.ColumnMeta{Name: "Cert Expiry", Tooltip: "Certificate expiry time", Type: funcapi.FieldTypeTimestamp, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Sortable: true, Transform: funcapi.FieldTransformDatetime}, Value: func(r licenseRow, _ time.Time) any {
117 + return licenseTimestampCell(r.CertificateExpiry, r.HasCertificateTime)
118 + }},
119 + {ColumnMeta: funcapi.ColumnMeta{Name: "Grace Remaining", Tooltip: "Grace/evaluation time remaining", Type: funcapi.FieldTypeDuration, Units: "milliseconds", Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Sortable: true, Transform: funcapi.FieldTransformDuration}, Value: func(r licenseRow, ts time.Time) any { return licenseRemainingCell(r.GraceExpiry, r.HasGraceTime, ts) }},
120 + {ColumnMeta: funcapi.ColumnMeta{Name: "Grace Expiry", Tooltip: "Grace/evaluation expiry time", Type: funcapi.FieldTypeTimestamp, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryMin, Filter: funcapi.FieldFilterRange, Sortable: true, Transform: funcapi.FieldTransformDatetime}, Value: func(r licenseRow, _ time.Time) any { return licenseTimestampCell(r.GraceExpiry, r.HasGraceTime) }},
121 + {ColumnMeta: funcapi.ColumnMeta{Name: "Usage", Tooltip: "Used license units", Type: funcapi.FieldTypeInteger, Units: "licenses", Visible: true, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Filter: funcapi.FieldFilterRange, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return licenseIntCell(r.Usage, r.HasUsage) }},
122 + {ColumnMeta: funcapi.ColumnMeta{Name: "Capacity", Tooltip: "Total license capacity", Type: funcapi.FieldTypeInteger, Units: "licenses", Visible: true, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Filter: funcapi.FieldFilterRange, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return licenseIntCell(r.Capacity, r.HasCapacity) }},
123 + {ColumnMeta: funcapi.ColumnMeta{Name: "Available", Tooltip: "Available license units", Type: funcapi.FieldTypeInteger, Units: "licenses", Visible: false, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Filter: funcapi.FieldFilterRange, Sortable: true}, Value: func(r licenseRow, _ time.Time) any { return licenseIntCell(r.Available, r.HasAvailable) }},
124 + {ColumnMeta: funcapi.ColumnMeta{Name: "Usage %", Tooltip: "License usage percentage", Type: funcapi.FieldTypeFloat, Units: "percentage", Visible: true, Sort: funcapi.FieldSortDescending, Summary: funcapi.FieldSummaryMax, Filter: funcapi.FieldFilterRange, Sortable: true, Visualization: funcapi.FieldVisualBar, Transform: funcapi.FieldTransformNumber, DecimalPoints: 2}, Value: func(r licenseRow, _ time.Time) any { return licensePercentCell(r.UsagePercent, r.HasUsagePct) }},
125 + {ColumnMeta: funcapi.ColumnMeta{Name: "Unlimited", Tooltip: "Unlimited license pool", Type: funcapi.FieldTypeBoolean, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true, Visualization: funcapi.FieldVisualPill}, Value: func(r licenseRow, _ time.Time) any { return r.IsUnlimited }},
126 + {ColumnMeta: funcapi.ColumnMeta{Name: "Perpetual", Tooltip: "Perpetual license", Type: funcapi.FieldTypeBoolean, Visible: false, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true, Visualization: funcapi.FieldVisualPill}, Value: func(r licenseRow, _ time.Time) any { return r.IsPerpetual }},
127 + {ColumnMeta: funcapi.ColumnMeta{Name: "Impact", Tooltip: "Operational impact", Type: funcapi.FieldTypeString, Visible: true, Sort: funcapi.FieldSortAscending, Summary: funcapi.FieldSummaryCount, Filter: funcapi.FieldFilterMultiselect, Sortable: true, FullWidth: true, Wrap: true}, Value: func(r licenseRow, _ time.Time) any { return emptyToNil(r.Impact) }},
128 +}
129 +
130 +func buildLicenseColumns(cs funcapi.ColumnSet[licenseColumn]) map[string]any {
131 + columns := cs.BuildColumns()
132 + rowOptions := funcapi.Column{
133 + Index: cs.Len(),
134 + Name: "rowOptions",
135 + Type: funcapi.FieldTypeNone,
136 + Visualization: funcapi.FieldVisualRowOptions,
137 + Sort: funcapi.FieldSortAscending,
138 + Sortable: false,
139 + Sticky: false,
140 + Summary: funcapi.FieldSummaryCount,
141 + Filter: funcapi.FieldFilterNone,
142 + Visible: false,
143 + Dummy: true,
144 + ValueOptions: funcapi.ValueOptions{
145 + Transform: funcapi.FieldTransformNone,
146 + DecimalPoints: 0,
147 + DefaultValue: nil,
148 + },
149 + }
150 + columns["rowOptions"] = rowOptions.BuildColumn()
151 + return columns
152 +}
153 +
154 +func buildLicenseFunctionRow(row licenseRow, ts time.Time) []any {
155 + data := make([]any, len(licenseAllColumns)+1)
156 + for i, col := range licenseAllColumns {
157 + data[i] = col.Value(row, ts)
158 + }
159 + data[len(licenseAllColumns)] = nil
160 + return data
161 +}
162 +
163 +func defaultLicenseSortColumn() string {
164 + for _, col := range licenseAllColumns {
165 + if col.DefaultSort {
166 + return col.Name
167 + }
168 + }
169 + return "License"
170 +}
171 +
172 +func sortLicenseRows(rows []licenseRow) {
173 + sort.SliceStable(rows, func(i, j int) bool {
174 + left := rows[i]
175 + right := rows[j]
176 +
177 + // Put actionable rows first; the UI can still default-sort by License.
178 + if lp, rp := licenseBucketPriority(left.StateBucket), licenseBucketPriority(right.StateBucket); lp != rp {
179 + return lp < rp
180 + }
181 + if leftDisplay, rightDisplay := licenseDisplayValue(left), licenseDisplayValue(right); leftDisplay != rightDisplay {
182 + return leftDisplay < rightDisplay
183 + }
184 + if left.Source != right.Source {
185 + return left.Source < right.Source
186 + }
187 + if left.Table != right.Table {
188 + return left.Table < right.Table
189 + }
190 + return left.ID < right.ID
191 + })
192 +}
193 +
194 +func licenseDisplayValue(row licenseRow) string {
195 + return firstNonBlank(row.Name, row.ID)
196 +}
197 +
198 +func licenseBucketPriority(bucket licenseStateBucket) int {
199 + switch bucket {
200 + case licenseStateBucketBroken:
201 + return 0
202 + case licenseStateBucketDegraded:
203 + return 1
204 + case licenseStateBucketInformational:
205 + return 2
206 + case licenseStateBucketHealthy:
207 + return 3
208 + default:
209 + return 4
210 + }
211 +}
212 +
213 +func licenseRowUniqueKey(row licenseRow) string {
214 + return licenseKeyParts(row.Source, row.Table, firstNonBlank(row.StructuralID, row.ID))
215 +}
216 +
217 +func licenseKeyParts(parts ...string) string {
218 + var b strings.Builder
219 + for _, part := range parts {
220 + b.WriteString(strconv.Itoa(len(part)))
221 + b.WriteByte(':')
222 + b.WriteString(part)
223 + }
224 + return b.String()
225 +}
226 +
227 +func licenseRemainingCell(expiry int64, ok bool, ts time.Time) any {
228 + if !ok {
229 + return nil
230 + }
231 + return (expiry - ts.Unix()) * 1000
232 +}
233 +
234 +func licenseTimestampCell(expiry int64, ok bool) any {
235 + if !ok {
236 + return nil
237 + }
238 + return time.Unix(expiry, 0).UnixMilli()
239 +}
240 +
241 +func licenseIntCell(value int64, ok bool) any {
242 + if !ok {
243 + return nil
244 + }
245 + return value
246 +}
247 +
248 +func licensePercentCell(value float64, ok bool) any {
249 + if !ok {
250 + return nil
251 + }
252 + return value
253 +}
254 +
255 +func emptyToNil(v string) any {
256 + if v == "" {
257 + return nil
258 + }
259 + return v
260 +}
src/go/plugin/go.d/collector/snmp/func_licenses_test.go new
+256
@@ -0,0 +1,256 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "context"
7 + "testing"
8 + "time"
9 +
10 + "github.com/stretchr/testify/assert"
11 + "github.com/stretchr/testify/require"
12 +)
13 +
14 +func newTestFuncLicenses(cache *licenseCache) *funcLicenses {
15 + return newFuncLicenses(cache)
16 +}
17 +
18 +func TestLicensesMethodConfig_Parameterless(t *testing.T) {
19 + tests := map[string]struct {
20 + method string
21 + }{
22 + "licenses method has no required params": {
23 + method: licensesMethodID,
24 + },
25 + }
26 +
27 + for name, tc := range tests {
28 + t.Run(name, func(t *testing.T) {
29 + cfg := licensesMethodConfig()
30 + assert.Empty(t, cfg.RequiredParams)
31 +
32 + params, err := newTestFuncLicenses(newLicenseCache()).MethodParams(context.Background(), tc.method)
33 + require.NoError(t, err)
34 + assert.Empty(t, params)
35 + })
36 + }
37 +}
38 +
39 +func TestFuncLicensesHandle(t *testing.T) {
40 + cache := newLicenseCache()
41 + now := time.Date(2026, time.April, 3, 10, 0, 0, 0, time.UTC)
42 + cache.store(now, []licenseRow{
43 + {
44 + ID: "broken-license",
45 + Source: "vendor",
46 + Name: "Shared License",
47 + StateRaw: "expired",
48 + StateBucket: licenseStateBucketBroken,
49 + ExpiryTS: now.Add(-time.Hour).Unix(),
50 + HasExpiry: true,
51 + UsagePercent: 100,
52 + HasUsagePct: true,
53 + Impact: "Feature disabled",
54 + },
55 + {
56 + ID: "eval-license",
57 + Source: "vendor",
58 + Name: "Evaluation License",
59 + StateRaw: "evaluation",
60 + StateBucket: licenseStateBucketInformational,
61 + },
62 + {
63 + ID: "healthy-license",
64 + Source: "vendor",
65 + Name: "Shared License",
66 + StateRaw: "active",
67 + StateBucket: licenseStateBucketHealthy,
68 + ExpiryTS: now.Add(24 * time.Hour).Unix(),
69 + HasExpiry: true,
70 + UsagePercent: 45,
71 + HasUsagePct: true,
72 + },
73 + {
74 + ID: "ignored-license",
75 + Source: "vendor",
76 + Name: "Unused License",
77 + StateRaw: "not_subscribed",
78 + StateBucket: licenseStateBucketIgnored,
79 + IsPerpetual: true,
80 + },
81 + })
82 +
83 + resp := newTestFuncLicenses(cache).Handle(context.Background(), licensesMethodID, nil)
84 + require.NotNil(t, resp)
85 + assert.Equal(t, 200, resp.Status)
86 + require.NotNil(t, resp.Columns)
87 + require.Len(t, resp.Data.([][]any), 4)
88 + assert.Equal(t, "License", resp.DefaultSortColumn)
89 +
90 + rows := resp.Data.([][]any)
91 + assert.Equal(t, "Shared License", rows[0][0])
92 + assert.Equal(t, string(licenseStateBucketBroken), rows[0][1])
93 + assert.Equal(t, "Evaluation License", rows[1][0])
94 + assert.Equal(t, string(licenseStateBucketInformational), rows[1][1])
95 + assert.Equal(t, "Shared License", rows[2][0])
96 + assert.Equal(t, "Unused License", rows[3][0])
97 + assert.Equal(t, string(licenseStateBucketIgnored), rows[3][1])
98 + assert.NotEqual(t, rows[0][4], rows[2][4], "hidden row ID should stay unique when display labels collide")
99 +
100 + rowOptions, ok := resp.Columns["rowOptions"]
101 + require.True(t, ok)
102 + assert.NotNil(t, rowOptions)
103 +
104 + columns := resp.Columns
105 + idCol, ok := columns["ID"].(map[string]any)
106 + require.True(t, ok)
107 + assert.Equal(t, false, idCol["visible"])
108 + assert.Equal(t, true, idCol["unique_key"])
109 +}
110 +
111 +func TestFuncLicensesHandle_DefaultSortUsesDisplayedLicenseValue(t *testing.T) {
112 + tests := map[string]struct {
113 + rows []licenseRow
114 + wantOrder []string
115 + }{
116 + "uses display label before hidden row id": {
117 + rows: []licenseRow{
118 + {
119 + ID: "Zulu",
120 + Source: "vendor",
121 + StateBucket: licenseStateBucketHealthy,
122 + },
123 + {
124 + ID: "alpha-id",
125 + Source: "vendor",
126 + Name: "Alpha",
127 + StateBucket: licenseStateBucketHealthy,
128 + },
129 + },
130 + wantOrder: []string{"Alpha", "Zulu"},
131 + },
132 + }
133 +
134 + for name, tc := range tests {
135 + t.Run(name, func(t *testing.T) {
136 + cache := newLicenseCache()
137 + cache.store(time.Date(2026, time.April, 3, 10, 0, 0, 0, time.UTC), tc.rows)
138 +
139 + resp := newTestFuncLicenses(cache).Handle(context.Background(), licensesMethodID, nil)
140 + require.NotNil(t, resp)
141 + require.Equal(t, 200, resp.Status)
142 +
143 + rows := resp.Data.([][]any)
144 + require.Len(t, rows, len(tc.wantOrder))
145 + for i, want := range tc.wantOrder {
146 + assert.Equal(t, want, rows[i][0])
147 + }
148 + })
149 + }
150 +}
151 +
152 +func TestLicenseRowUniqueKey_DistinctRows(t *testing.T) {
153 + tests := map[string]struct {
154 + left licenseRow
155 + right licenseRow
156 + }{
157 + "escapes delimiter content": {
158 + left: licenseRow{
159 + Source: "source|a",
160 + Table: "table",
161 + ID: "id",
162 + },
163 + right: licenseRow{
164 + Source: "source",
165 + Table: "table|id",
166 + ID: "",
167 + },
168 + },
169 + "different tables stay distinct": {
170 + left: licenseRow{
171 + Source: "fortinet",
172 + Table: "fgLicContractTable",
173 + ID: "FortiCare",
174 + Name: "FortiCare",
175 + },
176 + right: licenseRow{
177 + Source: "fortinet",
178 + Table: "fgLicVersionTable",
179 + ID: "FortiCare",
180 + Name: "FortiCare",
181 + },
182 + },
183 + }
184 +
185 + for name, tc := range tests {
186 + t.Run(name, func(t *testing.T) {
187 + assert.NotEqual(t, licenseRowUniqueKey(tc.left), licenseRowUniqueKey(tc.right))
188 + })
189 + }
190 +}
191 +
192 +func TestFuncLicensesHandleUnavailable(t *testing.T) {
193 + tests := map[string]struct {
194 + prepare func(cache *licenseCache)
195 + }{
196 + "before first collect": {},
197 + "when no rows were collected": {
198 + prepare: func(cache *licenseCache) {
199 + cache.store(time.Date(2026, time.April, 3, 10, 0, 0, 0, time.UTC), nil)
200 + },
201 + },
202 + }
203 +
204 + for name, tc := range tests {
205 + t.Run(name, func(t *testing.T) {
206 + cache := newLicenseCache()
207 + if tc.prepare != nil {
208 + tc.prepare(cache)
209 + }
210 +
211 + resp := newTestFuncLicenses(cache).Handle(context.Background(), licensesMethodID, nil)
212 + require.NotNil(t, resp)
213 + assert.Equal(t, 503, resp.Status)
214 + })
215 + }
216 +}
217 +
218 +func TestFuncLicensesRemainingUsesCurrentTime(t *testing.T) {
219 + tests := map[string]struct {
220 + lastUpdate time.Time
221 + expiry time.Time
222 + }{
223 + "uses current time instead of cache update time": {
224 + lastUpdate: time.Now().UTC().Add(-time.Hour),
225 + expiry: time.Now().UTC().Add(time.Hour),
226 + },
227 + }
228 +
229 + for name, tc := range tests {
230 + t.Run(name, func(t *testing.T) {
231 + cache := newLicenseCache()
232 + cache.store(tc.lastUpdate, []licenseRow{
233 + {
234 + ID: "license-1",
235 + Name: "Time Sensitive",
236 + StateBucket: licenseStateBucketHealthy,
237 + ExpiryTS: tc.expiry.Unix(),
238 + HasExpiry: true,
239 + },
240 + })
241 +
242 + resp := newTestFuncLicenses(cache).Handle(context.Background(), licensesMethodID, nil)
243 + require.NotNil(t, resp)
244 + require.Equal(t, 200, resp.Status)
245 +
246 + rows := resp.Data.([][]any)
247 + require.Len(t, rows, 1)
248 +
249 + remaining, ok := rows[0][8].(int64)
250 + require.True(t, ok)
251 + assert.GreaterOrEqual(t, remaining, int64((59*time.Minute)/time.Millisecond))
252 + assert.LessOrEqual(t, remaining, int64((61*time.Minute)/time.Millisecond))
253 + assert.Less(t, remaining, int64((2*time.Hour)/time.Millisecond))
254 + })
255 + }
256 +}
src/go/plugin/go.d/collector/snmp/func_router.go
+14 -4
@@ -17,16 +17,26 @@ type funcRouter struct {
17 handlers map[string]funcapi.MethodHandler
18 }
19
20 -func newFuncRouter(cache *ifaceCache) *funcRouter {
20 +func newFuncRouter(ifaceCache *ifaceCache) *funcRouter {
21 r := &funcRouter{
22 - ifaceCache: cache,
22 + ifaceCache: ifaceCache,
23 handlers: make(map[string]funcapi.MethodHandler),
24 }
25 - r.handlers[ifacesMethodID] = newFuncInterfaces(r)
25 + r.registerHandler(ifacesMethodID, newFuncInterfaces(r))
26 addTopologyFunctionHandler(r.handlers)
27 return r
28 }
29
30 +func (r *funcRouter) registerHandler(method string, handler funcapi.MethodHandler) {
31 + if r == nil || handler == nil {
32 + return
33 + }
34 + if r.handlers == nil {
35 + r.handlers = make(map[string]funcapi.MethodHandler)
36 + }
37 + r.handlers[method] = handler
38 +}
39 +
40 // Compile-time interface check.
41 var _ funcapi.MethodHandler = (*funcRouter)(nil)
42
@@ -50,7 +60,7 @@ func (r *funcRouter) Cleanup(ctx context.Context) {
60 }
61 }
62
53 -func snmpMethods() []funcapi.MethodConfig {
63 +func snmpBaseMethods() []funcapi.MethodConfig {
64 methods := []funcapi.MethodConfig{
65 ifacesMethodConfig(),
66 }
src/go/plugin/go.d/collector/snmp/integrations/snmp_devices.md
+122 -6
@@ -4,7 +4,7 @@ meta_yaml: "https://github.com/netdata/netdata/edit/master/src/go/plugin/go.d/co
4 sidebar_label: "SNMP devices"
5 learn_status: "Published"
6 learn_rel_path: "Collecting Metrics/Collectors/Networking"
7 -keywords: ['snmp', 'mib', 'oid', 'network', 'router', 'switch', 'firewall', 'ap', 'access point', 'wireless controller', 'wlc', 'wifi', 'vpn', 'pdu', 'ups', 'nas', 'san', 'printer', 'bgp', 'ospf', 'ucd', '3com', 'a10', 'alcatel', 'lucent', 'nokia', 'anue', 'apc', 'netbotz', 'arista', 'aruba', 'audiocodes', 'avaya', 'avocent', 'avtech', 'roomalert', 'barracuda', 'bluecat', 'brocade', 'cumulus', 'brother', 'chatsworth', 'checkpoint', 'chrysalis', 'cisco', 'cisco asa', 'cisco asr', 'cisco catalyst', 'cisco nexus', 'cisco ironport', 'cisco ics', 'cisco wlc', 'cisco ucs', 'meraki', 'citrix', 'netscaler', 'cradlepoint', 'cyberpower', 'dell', 'dell emc', 'poweredge', 'sonicwall', 'dialogic', 'dlink', 'd-link', 'eaton', 'exagrid', 'extreme', 'f5', 'big-ip', 'fireeye', 'fortinet', 'fortigate', 'fortiswitch', 'gigamon', 'hp', 'hewlett packard', 'hp ilo', 'ilo', 'ilo4', 'hp h3c', 'hp icf', 'hpe', 'proliant', 'huawei', '3com huawei', 'ibm', 'datapower', 'lenovo', 'idrac', 'dell idrac', 'infinera', 'coriant', 'infoblox', 'isilon', 'ixsystems', 'truenas', 'juniper', 'junos', 'kyocera', 'linksys', 'mcafee', 'mikrotik', 'mellanox', 'nasuni', 'nec', 'net-snmp', 'netsnmp', 'netapp', 'netgear', 'readynas', 'omron', 'opengear', 'palo alto', 'cloudgenix', 'peplink', 'raritan', 'riverbed', 'ruckus', 'serveriron', 'server-iron', 'servertech', 'silverpeak', 'silver peak', 'edgeconnect', 'sinetica', 'sophos', 'synology', 'diskstation', 'tp-link', 'tplink', 'tripplite', 'tripp lite', 'ubiquiti', 'unifi', 'velocloud', 'vertiv', 'liebert', 'watchguard', 'western digital', 'wd', 'mycloud', 'zebra', 'zyxel']
7 +keywords: ['snmp', 'mib', 'oid', 'network', 'router', 'switch', 'firewall', 'ap', 'access point', 'wireless controller', 'wlc', 'wifi', 'vpn', 'pdu', 'ups', 'nas', 'san', 'printer', 'bgp', 'ospf', 'ucd', '3com', 'a10', 'alcatel', 'lucent', 'nokia', 'anue', 'apc', 'netbotz', 'arista', 'aruba', 'audiocodes', 'avaya', 'avocent', 'avtech', 'roomalert', 'barracuda', 'bluecat', 'brocade', 'cumulus', 'brother', 'chatsworth', 'checkpoint', 'chrysalis', 'cisco', 'cisco asa', 'cisco asr', 'cisco catalyst', 'cisco nexus', 'cisco ironport', 'cisco ics', 'cisco wlc', 'cisco ucs', 'meraki', 'citrix', 'netscaler', 'cradlepoint', 'cyberpower', 'dell', 'dell emc', 'poweredge', 'sonicwall', 'dialogic', 'dlink', 'd-link', 'eaton', 'exagrid', 'extreme', 'f5', 'big-ip', 'fireeye', 'bluecoat', 'proxysg', 'sgos', 'symantec', 'fortinet', 'fortigate', 'fortiswitch', 'gigamon', 'hp', 'hewlett packard', 'hp ilo', 'ilo', 'ilo4', 'hp h3c', 'hp icf', 'hpe', 'proliant', 'huawei', '3com huawei', 'ibm', 'datapower', 'lenovo', 'idrac', 'dell idrac', 'infinera', 'coriant', 'infoblox', 'isilon', 'ixsystems', 'truenas', 'juniper', 'junos', 'kyocera', 'linksys', 'mcafee', 'mikrotik', 'mellanox', 'nasuni', 'nec', 'net-snmp', 'netsnmp', 'netapp', 'netgear', 'readynas', 'omron', 'opengear', 'palo alto', 'cloudgenix', 'peplink', 'raritan', 'riverbed', 'ruckus', 'serveriron', 'server-iron', 'servertech', 'silverpeak', 'silver peak', 'edgeconnect', 'sinetica', 'sophos', 'synology', 'diskstation', 'tp-link', 'tplink', 'tripplite', 'tripp lite', 'ubiquiti', 'unifi', 'velocloud', 'vertiv', 'liebert', 'watchguard', 'western digital', 'wd', 'mycloud', 'zebra', 'zyxel']
8 message: "DO NOT EDIT THIS FILE DIRECTLY, IT IS GENERATED BY THE COLLECTOR'S metadata.yaml FILE"
9 endmeta-->
10
@@ -28,6 +28,8 @@ This collector discovers and monitors any SNMP-enabled network device.
28 - **Automatic vendor/model detection**: Devices are matched to the right profile using selectors such as `sysObjectID` and `sysDescr`.
29 - **ICMP ping**: Optional round-trip latency monitoring alongside SNMP, with a `ping_only` mode available.
30 - **SNMP v1, v2c, and v3 support**: Fully implemented via the [gosnmp](https://github.com/gosnmp/gosnmp) library.
31 +- **Shared device-level licensing metrics for supported profiles**: When a profile exposes license telemetry, Netdata emits compact per-device licensing charts for earliest expiry, license-state counts (`healthy`, `informational`, `degraded`, `broken`, `ignored`), and highest usage pressure. Current branch coverage includes Check Point licensing state and per-blade expiry, Fortinet FortiGate contract/service/account expirations, Cisco traditional licensing end-date/remaining-time/state/usage telemetry, Cisco Smart Licensing authorization, certificate, evaluation, and state telemetry, Sophos Firewall subscription state and per-license expiry telemetry, Blue Coat ProxySG application/feature/component expiry, expire-type, and state telemetry, and basic MikroTik RouterOS upgrade-entitlement telemetry. For MikroTik, epoch-like placeholder `mtxrLicUpgrUntil` values are ignored instead of treated as real expired licenses.
32 +- **Interactive licensing drill-down**: The `snmp:licenses` function follows the existing SNMP function pattern and shows normalized licensing rows for the selected SNMP job/device using cached collector data.
33
34
35 **Built-in profiles for major vendors:**
@@ -35,7 +37,7 @@ This collector discovers and monitors any SNMP-enabled network device.
37 | Category | Vendors |
38 |----------|---------|
39 | Switches & Routers | Cisco (Catalyst, Nexus, ASR, ISR), Arista, Juniper, HP/HPE, Dell, Extreme |
38 -| Firewalls | Palo Alto, Fortinet FortiGate, Cisco ASA, Checkpoint, SonicWall |
40 +| Firewalls | Palo Alto, Fortinet FortiGate, Cisco ASA, Checkpoint, SonicWall, Sophos |
41 | Wireless | Aruba, Cisco WLC, Ubiquiti, Alcatel-Lucent |
42 | Load Balancers | F5 BIG-IP, Citrix NetScaler, A10 Thunder |
43 | Infrastructure | APC UPS/PDU, Dell servers, plus standard MIBs (BGP, OSPF, TCP/UDP) |
@@ -361,13 +363,54 @@ jobs:
363
364 ## Alerts
365
364 -There are no alerts configured by default for this integration.
366 +
367 +The following alerts are available:
368 +
369 +| Alert name | On metric | Description |
370 +|:------------|:----------|:------------|
371 +| [snmp_license_expiring](https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf) | snmp.license.remaining_time | The earliest monitored SNMP license or subscription on this device is close to expiration. |
372 +| [snmp_license_authorization_expiring](https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf) | snmp.license.authorization_remaining_time | The license authorization timer on this device is close to expiration. |
373 +| [snmp_license_certificate_expiring](https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf) | snmp.license.certificate_remaining_time | The license certificate timer on this device is close to expiration. |
374 +| [snmp_license_grace_period_ending](https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf) | snmp.license.grace_remaining_time | The licensing grace or evaluation period on this device is ending or already expired. |
375 +| [snmp_license_state_warning](https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf) | snmp.license.state | One or more monitored licenses on this device are degraded, in grace, or otherwise in warning state. |
376 +| [snmp_license_state_critical](https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf) | snmp.license.state | One or more monitored licenses on this device are expired, invalid, unauthorized, or otherwise in critical state. |
377 +| [snmp_license_usage_high](https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf) | snmp.license.usage_percent | The most constrained monitored license pool on this device is nearing exhaustion. |
378
379
380 ## Metrics
381
382 +Metrics grouped by *scope*.
383 +
384 +The scope defines the instance that the metric belongs to.
385 +An instance is uniquely identified by a set of labels.
386 +
387 Metrics and charts are **defined by the matched SNMP profile(s)** at runtime. They differ by vendor/model/OS and may include, for example, interface counters, optics, CPU/memory, temperature, VLANs, and more. Use the **Metrics** tab on the device’s dashboard to see exactly what is collected for that device.
388
389 +Supported licensing profiles also emit a small set of shared **device-level licensing contexts**:
390 +
391 +- `snmp.license.remaining_time`: earliest remaining time to expiry across monitored licenses and subscriptions on the device
392 +- `snmp.license.authorization_remaining_time`: earliest remaining time for license authorization timers
393 +- `snmp.license.certificate_remaining_time`: earliest remaining time for licensing certificate timers
394 +- `snmp.license.grace_remaining_time`: earliest remaining time for grace or evaluation timers
395 +- `snmp.license.usage_percent`: highest license pool pressure across finite usage pools on the device
396 +- `snmp.license.state`: count of licensing rows on the device by normalized state bucket (`healthy`, `informational`, `degraded`, `broken`, `ignored`)
397 +
398 +The licensing charts are intentionally aggregated to keep one clean device view:
399 +
400 +- Netdata reports the **earliest** expiry-related deadline on the device.
401 +- Netdata reports how many licensing rows are **healthy**, **informational**, **degraded**, **broken**, and **ignored** on the device.
402 +- Netdata reports the **highest** usage pressure across finite license pools on the device.
403 +
404 +Licensing support is intentionally conditional:
405 +
406 +- licensing charts appear only for devices whose matched SNMP profile exposes licensing telemetry
407 +- not every supported device exposes all licensing dimensions
408 +- `remaining_time` and related time charts appear only when the device exposes expiry-like timers
409 +- `usage_percent` appears only when the device exposes finite usage and capacity data
410 +- `snmp.license.state` appears only when the device exposes enough state/compliance/validity data to normalize rows
411 +
412 +Default alerts from `src/health/health.d/snmp.conf` use `30d/7d` for expiry-related timers, `7d/0d` for grace timers, and `80/95` for usage pressure. You can override them like any other Netdata health configuration.
413 +
414 :::tip
415
416 To understand the structure of these profiles (metrics, tags, virtual metrics, etc.), see **[SNMP Profile Format](https://github.com/netdata/netdata/blob/master/src/go/plugin/go.d/collector/snmp/profile-format.md)**.
@@ -377,6 +420,28 @@ To understand the structure of these profiles (metrics, tags, virtual metrics, e
420 If `ping.enabled` is true, ICMP latency/packet-loss charts are also provided (or exclusively, when `ping_only: true`).
421
422
423 +### Per device licensing
424 +
425 +Shared device-level licensing health metrics emitted when the matched SNMP profile provides licensing telemetry. Current branch coverage includes Check Point licensing state and per-blade expiry, Fortinet FortiGate contract/service/account expirations, Cisco traditional licensing end-date/remaining-time/state/usage telemetry, Cisco Smart Licensing authorization, certificate, evaluation, and state telemetry, Sophos Firewall subscription state and per-license expiry telemetry, Blue Coat ProxySG application/feature/component expiry, expire-type, and state telemetry, and basic MikroTik RouterOS upgrade-entitlement telemetry. MikroTik support is intentionally limited to the RouterOS upgrade-entitlement fields exposed by SNMP, and epoch-like placeholder `mtxrLicUpgrUntil` values are ignored.
426 +
427 +Labels:
428 +
429 +| Label | Description |
430 +|:-----------|:----------------|
431 +| component | Always `licensing` for the shared SNMP licensing charts. |
432 +
433 +Metrics:
434 +
435 +| Metric | Dimensions | Unit |
436 +|:------|:----------|:----|
437 +| snmp.license.remaining_time | remaining_time | seconds |
438 +| snmp.license.authorization_remaining_time | remaining_time | seconds |
439 +| snmp.license.certificate_remaining_time | remaining_time | seconds |
440 +| snmp.license.grace_remaining_time | remaining_time | seconds |
441 +| snmp.license.usage_percent | usage_percent | percentage |
442 +| snmp.license.state | healthy, informational, degraded, broken, ignored | licenses |
443 +
444 +
445
446 ## Live Data
447
@@ -488,6 +553,60 @@ Agent-wide topology data in a JSON schema suitable for cross-agent aggregation.
553 | links | array | | | List of discovered links (LLDP/CDP). |
554 | stats | object | | | Summary stats (device/link counts). |
555
556 +### Licenses
557 +
558 +Provides normalized licensing rows for the selected SNMP device.
559 +
560 +This function reads the collector's cached licensing rows and shows one row per normalized license entry. It is the drill-down view behind the device-level licensing charts: expiry timers, authorization/certificate/grace timers, usage/capacity, raw vendor state, normalized state bucket, and operational impact.
561 +
562 +It follows the same pattern as the SNMP `interfaces` function:
563 +- the function itself is global for the SNMP collector
564 +- the Live tab targets the selected SNMP job/device automatically using the standard SNMP function context
565 +- the returned rows belong only to that selected device
566 +
567 +Use cases:
568 +- See which exact license rows make the device show degraded or broken counts
569 +- Inspect expiry and grace timers for subscriptions, support, and smart-licensing signals
570 +- Review usage/capacity for finite license pools on the selected device
571 +
572 +Data is sourced from the last successful SNMP collection. No extra SNMP requests are triggered when calling this function.
573 +
574 +
575 +| Aspect | Description |
576 +|:-------|:------------|
577 +| Name | `Snmp:licenses` |
578 +| Require Cloud | no |
579 +| Performance | Uses cached SNMP data only, no additional SNMP requests are triggered:<br/>• Responses are instantaneous from memory cache<br/>• Large devices with many licensing rows may return many rows |
580 +| Security | Exposes licensing names, states, timers, counts, and impact notes only:<br/>• No credentials or secrets are exposed<br/>• No device configuration is modified |
581 +| Availability | Available when:<br/>• The collector has completed at least one licensing-aware data collection cycle that produced licensing rows<br/>• Licensing data is cached from the last successful SNMP collection<br/>• Returns HTTP 503 if cache is not ready yet or the device/profile exposes no licensing rows |
582 +
583 +#### Prerequisites
584 +
585 +No additional configuration is required.
586 +
587 +#### Parameters
588 +
589 +This function has no parameters.
590 +
591 +#### Returns
592 +
593 +Normalized licensing rows for the selected SNMP device. Each row represents one cached normalized licensing entry from the collector.
594 +
595 +| Column | Type | Unit | Visibility | Description |
596 +|:-------|:-----|:-----|:-----------|:------------|
597 +| License | string | | | Human-readable license row name, or the normalized license identifier when no name exists. |
598 +| ID | string | | hidden | Stable row identifier used by the UI to track one normalized licensing row across updates. |
599 +| Bucket | string | | | Normalized health bucket for the row: healthy, informational, degraded, broken, or ignored. |
600 +| State | string | | | Raw vendor licensing state when the device exposes one. |
601 +| Component | string | | | Normalized component or area associated with the license row. |
602 +| Type | string | | | Normalized license type such as subscription, certificate, authorization, evaluation, or usage pool. |
603 +| Remaining | duration | milliseconds | | Time remaining until the row's primary expiry, when applicable. |
604 +| Expiry | timestamp | | | Absolute expiry time for the row, when known. |
605 +| Usage | integer | licenses | | Used license units for finite pools. |
606 +| Capacity | integer | licenses | | Total capacity for finite license pools. |
607 +| Usage % | float | percentage | | Usage pressure for finite license pools. |
608 +| Impact | string | | | Operational impact text when the vendor or profile provides it. |
609 +
610
611
612 ## Troubleshooting
@@ -605,6 +724,3 @@ Table metrics are usually the slowest and often determine the total collection t
724 1. Do logs show “skipping data collection”?
725 2. Does *Internal → Stats* show collection time > `update_every`?
726 3. Increase `update_every` until skips disappear.
608 -
609 -
610 -
src/go/plugin/go.d/collector/snmp/licensing.go new
+244
@@ -0,0 +1,244 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "slices"
7 + "strings"
8 + "sync"
9 + "time"
10 +
11 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
12 +)
13 +
14 +// Licensing pipeline contract — profiles emit typed licensing rows through
15 +// ddsnmp.ProfileMetrics.LicenseRows. Hidden metrics remain a generic ddsnmp
16 +// transport but are not part of the licensing consumer contract.
17 +
18 +const (
19 + metricIDLicenseRemainingTime = "snmp_device_license_remaining_time"
20 + metricIDLicenseAuthorizationRemainingTime = "snmp_device_license_authorization_remaining_time"
21 + metricIDLicenseCertificateRemainingTime = "snmp_device_license_certificate_remaining_time"
22 + metricIDLicenseGraceRemainingTime = "snmp_device_license_grace_remaining_time"
23 + metricIDLicenseUsagePercent = "snmp_device_license_usage_percent"
24 + metricIDLicenseStateHealthy = "snmp_device_license_state_healthy"
25 + metricIDLicenseStateInformational = "snmp_device_license_state_informational"
26 + metricIDLicenseStateDegraded = "snmp_device_license_state_degraded"
27 + metricIDLicenseStateBroken = "snmp_device_license_state_broken"
28 + metricIDLicenseStateIgnored = "snmp_device_license_state_ignored"
29 +)
30 +
31 +type licenseRow struct {
32 + ID string
33 + StructuralID string
34 + Source string
35 + Table string
36 + Name string
37 +
38 + Feature string
39 + Component string
40 + Type string
41 + Impact string
42 +
43 + StateRaw string
44 + StateSeverity int64
45 + HasState bool
46 + StateBucket licenseStateBucket
47 +
48 + ExpiryTS int64
49 + HasExpiry bool
50 + AuthorizationExpiry int64
51 + HasAuthorizationTime bool
52 + CertificateExpiry int64
53 + HasCertificateTime bool
54 + GraceExpiry int64
55 + HasGraceTime bool
56 +
57 + Usage int64
58 + HasUsage bool
59 + Capacity int64
60 + HasCapacity bool
61 + Available int64
62 + HasAvailable bool
63 + UsagePercent float64
64 + HasUsagePct bool
65 +
66 + IsUnlimited bool
67 + IsPerpetual bool
68 +
69 + ExpirySource string
70 + AuthSource string
71 + CertSource string
72 + GraceSource string
73 +}
74 +
75 +type licenseCache struct {
76 + mu sync.RWMutex
77 + lastUpdate time.Time
78 + rows []licenseRow
79 +}
80 +
81 +func newLicenseCache() *licenseCache {
82 + return &licenseCache{}
83 +}
84 +
85 +func (c *licenseCache) store(ts time.Time, rows []licenseRow) {
86 + c.mu.Lock()
87 + defer c.mu.Unlock()
88 +
89 + c.lastUpdate = ts
90 + c.rows = slices.Clone(rows)
91 +}
92 +
93 +func (c *licenseCache) snapshot() (time.Time, []licenseRow) {
94 + c.mu.RLock()
95 + defer c.mu.RUnlock()
96 +
97 + return c.lastUpdate, slices.Clone(c.rows)
98 +}
99 +
100 +// extractLicenseRows converts typed ddsnmp licensing rows into the collector's
101 +// cached row shape. HiddenMetrics are intentionally ignored here: they remain a
102 +// generic ddsnmp transport, not a licensing protocol.
103 +func extractLicenseRows(pms []*ddsnmp.ProfileMetrics, now time.Time) []licenseRow {
104 + var rows []licenseRow
105 +
106 + for _, pm := range pms {
107 + if pm == nil {
108 + continue
109 + }
110 + for _, src := range pm.LicenseRows {
111 + row, ok := licenseRowFromTyped(pm, src, now)
112 + if !ok {
113 + continue
114 + }
115 + rows = append(rows, row)
116 + }
117 + }
118 +
119 + rows = dropLicenseRowsWithoutSignals(rows)
120 +
121 + for i := range rows {
122 + rows[i].StateBucket = normalizeLicenseStateBucket(rows[i], now)
123 + }
124 +
125 + return rows
126 +}
127 +
128 +func licenseRowFromTyped(pm *ddsnmp.ProfileMetrics, src ddsnmp.LicenseRow, now time.Time) (licenseRow, bool) {
129 + row := licenseRow{
130 + Source: licenseRowSource(pm, src),
131 + Table: licenseRowTable(src),
132 + ID: licenseRowID(src),
133 + StructuralID: firstNonBlank(src.StructuralID, src.RowKey, src.ID),
134 + Name: src.Name,
135 + Feature: src.Feature,
136 + Component: src.Component,
137 + Type: src.Type,
138 + Impact: src.Impact,
139 + IsUnlimited: src.IsUnlimited,
140 + IsPerpetual: src.IsPerpetual,
141 + StateRaw: src.State.Raw,
142 + StateSeverity: clampLicenseSeverity(src.State.Severity),
143 + HasState: src.State.Has,
144 + }
145 +
146 + setLicenseTimer(&row.ExpiryTS, &row.HasExpiry, &row.ExpirySource, src.Expiry, now)
147 + setLicenseTimer(&row.AuthorizationExpiry, &row.HasAuthorizationTime, &row.AuthSource, src.Authorization, now)
148 + setLicenseTimer(&row.CertificateExpiry, &row.HasCertificateTime, &row.CertSource, src.Certificate, now)
149 + setLicenseTimer(&row.GraceExpiry, &row.HasGraceTime, &row.GraceSource, src.Grace, now)
150 +
151 + row.Usage = src.Usage.Used
152 + row.HasUsage = src.Usage.HasUsed
153 + row.Capacity = src.Usage.Capacity
154 + row.HasCapacity = src.Usage.HasCapacity
155 + row.Available = src.Usage.Available
156 + row.HasAvailable = src.Usage.HasAvailable
157 + row.UsagePercent = float64(src.Usage.Percent)
158 + row.HasUsagePct = src.Usage.HasPercent
159 + deriveLicenseUsage(&row)
160 +
161 + return row, firstNonBlank(row.ID, row.StructuralID) != "" && licenseRowHasAnySignal(row)
162 +}
163 +
164 +func licenseRowSource(pm *ddsnmp.ProfileMetrics, src ddsnmp.LicenseRow) string {
165 + if strings.TrimSpace(src.OriginProfileID) != "" {
166 + return src.OriginProfileID
167 + }
168 + if pm == nil {
169 + return ""
170 + }
171 + return pm.Source
172 +}
173 +
174 +func licenseRowTable(src ddsnmp.LicenseRow) string {
175 + return firstNonBlank(src.TableOID, src.Table)
176 +}
177 +
178 +func licenseRowID(src ddsnmp.LicenseRow) string {
179 + return firstNonBlank(src.ID, src.StructuralID, src.RowKey)
180 +}
181 +
182 +func setLicenseTimer(dst *int64, has *bool, source *string, timer ddsnmp.LicenseTimer, now time.Time) {
183 + if !timer.Has {
184 + return
185 + }
186 + if timer.Timestamp != 0 {
187 + *dst = timer.Timestamp
188 + } else {
189 + *dst = now.Unix() + timer.RemainingSeconds
190 + }
191 + *has = true
192 + *source = timer.SourceOID
193 +}
194 +
195 +func clampLicenseSeverity(value int64) int64 {
196 + switch {
197 + case value < 0:
198 + return 0
199 + case value > 2:
200 + return 2
201 + default:
202 + return value
203 + }
204 +}
205 +
206 +func deriveLicenseUsage(row *licenseRow) {
207 + if !row.HasUsage && row.HasCapacity && row.HasAvailable && row.Available >= 0 && row.Available <= row.Capacity {
208 + row.Usage = row.Capacity - row.Available
209 + row.HasUsage = true
210 + }
211 + if !row.HasUsagePct && row.HasUsage && row.HasCapacity && row.Capacity > 0 && !row.IsUnlimited {
212 + row.UsagePercent = float64(row.Usage) * 100 / float64(row.Capacity)
213 + row.HasUsagePct = true
214 + }
215 +}
216 +
217 +func dropLicenseRowsWithoutSignals(rows []licenseRow) []licenseRow {
218 + return slices.DeleteFunc(rows, func(row licenseRow) bool {
219 + return !licenseRowHasAnySignal(row)
220 + })
221 +}
222 +
223 +// licenseRowHasAnySignal returns true when at least one merged signal
224 +// (state, expiry, auth/cert/grace timer, usage shape) is set on the row.
225 +func licenseRowHasAnySignal(row licenseRow) bool {
226 + return row.HasState ||
227 + row.HasExpiry ||
228 + row.HasAuthorizationTime ||
229 + row.HasCertificateTime ||
230 + row.HasGraceTime ||
231 + row.HasUsage ||
232 + row.HasCapacity ||
233 + row.HasAvailable ||
234 + row.HasUsagePct
235 +}
236 +
237 +func firstNonBlank(values ...string) string {
238 + for _, value := range values {
239 + if strings.TrimSpace(value) != "" {
240 + return value
241 + }
242 + }
243 + return ""
244 +}
src/go/plugin/go.d/collector/snmp/licensing_aggregate.go new
+118
@@ -0,0 +1,118 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "math"
7 + "time"
8 +)
9 +
10 +type licenseAggregate struct {
11 + remainingTime int64
12 + hasRemainingTime bool
13 + authRemainingTime int64
14 + hasAuthRemaining bool
15 + certRemainingTime int64
16 + hasCertRemaining bool
17 + graceRemainingTime int64
18 + hasGraceRemaining bool
19 + usagePercent int64
20 + hasUsagePercent bool
21 + stateHealthy int64
22 + stateInformational int64
23 + stateDegraded int64
24 + stateBroken int64
25 + stateIgnored int64
26 + hasStateCounts bool
27 +}
28 +
29 +func aggregateLicenseRows(rows []licenseRow, now time.Time) licenseAggregate {
30 + var agg licenseAggregate
31 +
32 + for _, row := range rows {
33 + switch row.StateBucket {
34 + case licenseStateBucketHealthy:
35 + agg.stateHealthy++
36 + agg.hasStateCounts = true
37 + case licenseStateBucketInformational:
38 + agg.stateInformational++
39 + agg.hasStateCounts = true
40 + case licenseStateBucketDegraded:
41 + agg.stateDegraded++
42 + agg.hasStateCounts = true
43 + case licenseStateBucketBroken:
44 + agg.stateBroken++
45 + agg.hasStateCounts = true
46 + case licenseStateBucketIgnored:
47 + agg.stateIgnored++
48 + agg.hasStateCounts = true
49 + }
50 +
51 + if row.StateBucket == licenseStateBucketIgnored {
52 + continue
53 + }
54 +
55 + if row.HasExpiry && !row.IsPerpetual {
56 + agg.setMin(row.ExpiryTS-now.Unix(), &agg.remainingTime, &agg.hasRemainingTime)
57 + }
58 + if row.HasAuthorizationTime {
59 + agg.setMin(row.AuthorizationExpiry-now.Unix(), &agg.authRemainingTime, &agg.hasAuthRemaining)
60 + }
61 + if row.HasCertificateTime {
62 + agg.setMin(row.CertificateExpiry-now.Unix(), &agg.certRemainingTime, &agg.hasCertRemaining)
63 + }
64 + if row.HasGraceTime {
65 + agg.setMin(row.GraceExpiry-now.Unix(), &agg.graceRemainingTime, &agg.hasGraceRemaining)
66 + }
67 + if row.HasUsagePct && !row.IsUnlimited {
68 + pct := int64(math.Round(row.UsagePercent))
69 + if !agg.hasUsagePercent || pct > agg.usagePercent {
70 + agg.usagePercent = pct
71 + agg.hasUsagePercent = true
72 + }
73 + }
74 + }
75 +
76 + return agg
77 +}
78 +
79 +func (agg *licenseAggregate) setMin(value int64, current *int64, seen *bool) {
80 + if !*seen || value < *current {
81 + *current = value
82 + *seen = true
83 + }
84 +}
85 +
86 +func (agg licenseAggregate) empty() bool {
87 + return !agg.hasRemainingTime &&
88 + !agg.hasAuthRemaining &&
89 + !agg.hasCertRemaining &&
90 + !agg.hasGraceRemaining &&
91 + !agg.hasUsagePercent &&
92 + !agg.hasStateCounts
93 +}
94 +
95 +func (agg licenseAggregate) writeTo(mx map[string]int64) {
96 + if agg.hasRemainingTime {
97 + mx[metricIDLicenseRemainingTime] = agg.remainingTime
98 + }
99 + if agg.hasAuthRemaining {
100 + mx[metricIDLicenseAuthorizationRemainingTime] = agg.authRemainingTime
101 + }
102 + if agg.hasCertRemaining {
103 + mx[metricIDLicenseCertificateRemainingTime] = agg.certRemainingTime
104 + }
105 + if agg.hasGraceRemaining {
106 + mx[metricIDLicenseGraceRemainingTime] = agg.graceRemainingTime
107 + }
108 + if agg.hasUsagePercent {
109 + mx[metricIDLicenseUsagePercent] = agg.usagePercent
110 + }
111 + if agg.hasStateCounts {
112 + mx[metricIDLicenseStateHealthy] = agg.stateHealthy
113 + mx[metricIDLicenseStateInformational] = agg.stateInformational
114 + mx[metricIDLicenseStateDegraded] = agg.stateDegraded
115 + mx[metricIDLicenseStateBroken] = agg.stateBroken
116 + mx[metricIDLicenseStateIgnored] = agg.stateIgnored
117 + }
118 +}
src/go/plugin/go.d/collector/snmp/licensing_aggregate_test.go new
+116
@@ -0,0 +1,116 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "testing"
7 + "time"
8 +
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
10 +
11 + "github.com/stretchr/testify/assert"
12 +)
13 +
14 +func TestAggregateLicenseRows_FromTypedRows(t *testing.T) {
15 + now := time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC)
16 + earliest := now.Add(2 * time.Hour).Unix()
17 + latest := now.Add(48 * time.Hour).Unix()
18 +
19 + tests := map[string]struct {
20 + rows []ddsnmp.LicenseRow
21 + assert func(t *testing.T, agg licenseAggregate)
22 + }{
23 + "selects min expiry and max usage": {
24 + rows: []ddsnmp.LicenseRow{
25 + typedLicenseRow("a", "First", withExpiry(latest), withUsage(30), withCapacity(100)),
26 + typedLicenseRow("b", "Second", withExpiry(earliest), withUsage(95), withCapacity(100)),
27 + },
28 + assert: func(t *testing.T, agg licenseAggregate) {
29 + assert.True(t, agg.hasRemainingTime)
30 + assert.Equal(t, earliest-now.Unix(), agg.remainingTime)
31 + assert.True(t, agg.hasUsagePercent)
32 + assert.EqualValues(t, 95, agg.usagePercent)
33 + },
34 + },
35 + "perpetual rows skip expiry aggregation": {
36 + rows: []ddsnmp.LicenseRow{
37 + typedLicenseRow("perp", "Perpetual", withExpiry(now.Add(time.Hour).Unix()), withPerpetual()),
38 + typedLicenseRow("real", "Subscription", withExpiry(now.Add(24*time.Hour).Unix())),
39 + },
40 + assert: func(t *testing.T, agg licenseAggregate) {
41 + assert.True(t, agg.hasRemainingTime)
42 + assert.Equal(t, int64((24 * time.Hour).Seconds()), agg.remainingTime)
43 + },
44 + },
45 + "unlimited rows skip usage aggregation": {
46 + rows: []ddsnmp.LicenseRow{
47 + typedLicenseRow("limited", "Limited", withUsagePercent(60)),
48 + typedLicenseRow("infinite", "Infinite", withUsagePercent(100), withUnlimited()),
49 + },
50 + assert: func(t *testing.T, agg licenseAggregate) {
51 + assert.True(t, agg.hasUsagePercent)
52 + assert.EqualValues(t, 60, agg.usagePercent)
53 + },
54 + },
55 + "state bucket counts include informational": {
56 + rows: []ddsnmp.LicenseRow{
57 + typedLicenseRow("a", "Healthy A", withState(0, "")),
58 + typedLicenseRow("b", "Healthy B", withState(0, "")),
59 + typedLicenseRow("c", "Informational", withState(1, "evaluation")),
60 + typedLicenseRow("d", "Degraded", withState(1, "")),
61 + typedLicenseRow("e", "Broken", withState(2, "")),
62 + typedLicenseRow("f", "Ignored", withState(0, "none")),
63 + },
64 + assert: func(t *testing.T, agg licenseAggregate) {
65 + assert.True(t, agg.hasStateCounts)
66 + assert.EqualValues(t, 2, agg.stateHealthy)
67 + assert.EqualValues(t, 1, agg.stateInformational)
68 + assert.EqualValues(t, 1, agg.stateDegraded)
69 + assert.EqualValues(t, 1, agg.stateBroken)
70 + assert.EqualValues(t, 1, agg.stateIgnored)
71 + },
72 + },
73 + "ignored rows do not drive signal aggregation": {
74 + rows: []ddsnmp.LicenseRow{
75 + typedLicenseRow("ignored", "Ignored",
76 + withRawState("not applicable"),
77 + withExpiry(now.Add(time.Hour).Unix()),
78 + withUsagePercent(99),
79 + ),
80 + typedLicenseRow("healthy", "Healthy",
81 + withExpiry(now.Add(24*time.Hour).Unix()),
82 + withUsagePercent(60),
83 + ),
84 + },
85 + assert: func(t *testing.T, agg licenseAggregate) {
86 + assert.True(t, agg.hasRemainingTime)
87 + assert.Equal(t, int64((24 * time.Hour).Seconds()), agg.remainingTime)
88 + assert.True(t, agg.hasUsagePercent)
89 + assert.EqualValues(t, 60, agg.usagePercent)
90 + assert.True(t, agg.hasStateCounts)
91 + assert.EqualValues(t, 1, agg.stateHealthy)
92 + assert.EqualValues(t, 1, agg.stateIgnored)
93 + },
94 + },
95 + "writeTo omits absent timer and usage signals": {
96 + rows: []ddsnmp.LicenseRow{
97 + typedLicenseRow("a", "A", withState(0, "")),
98 + },
99 + assert: func(t *testing.T, agg licenseAggregate) {
100 + mx := make(map[string]int64)
101 + agg.writeTo(mx)
102 + assert.NotContains(t, mx, metricIDLicenseRemainingTime)
103 + assert.NotContains(t, mx, metricIDLicenseUsagePercent)
104 + assert.Contains(t, mx, metricIDLicenseStateHealthy)
105 + assert.Contains(t, mx, metricIDLicenseStateInformational)
106 + },
107 + },
108 + }
109 +
110 + for name, tc := range tests {
111 + t.Run(name, func(t *testing.T) {
112 + rows := extractLicenseRows(profileWithRows(tc.rows...), now)
113 + tc.assert(t, aggregateLicenseRows(rows, now))
114 + })
115 + }
116 +}
src/go/plugin/go.d/collector/snmp/licensing_charts.go new
+134
@@ -0,0 +1,134 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import "github.com/netdata/netdata/go/plugins/plugin/framework/collectorapi"
6 +
7 +const (
8 + prioLicenseRemainingTime = prioPingStdDev + 1 + iota
9 + prioLicenseAuthorizationRemainingTime
10 + prioLicenseCertificateRemainingTime
11 + prioLicenseGraceRemainingTime
12 + prioLicenseUsagePercent
13 + prioLicenseState
14 +)
15 +
16 +var (
17 + licenseRemainingTimeChart = collectorapi.Chart{
18 + ID: "snmp_device_license_remaining_time",
19 + Title: "License remaining time",
20 + Units: "seconds",
21 + Fam: "Licensing/Time",
22 + Ctx: "snmp.license.remaining_time",
23 + Priority: prioLicenseRemainingTime,
24 + SkipGaps: true,
25 + Dims: collectorapi.Dims{
26 + {ID: "snmp_device_license_remaining_time", Name: "remaining_time"},
27 + },
28 + }
29 + licenseAuthorizationRemainingTimeChart = collectorapi.Chart{
30 + ID: "snmp_device_license_authorization_remaining_time",
31 + Title: "License authorization remaining time",
32 + Units: "seconds",
33 + Fam: "Licensing/Time",
34 + Ctx: "snmp.license.authorization_remaining_time",
35 + Priority: prioLicenseAuthorizationRemainingTime,
36 + SkipGaps: true,
37 + Dims: collectorapi.Dims{
38 + {ID: "snmp_device_license_authorization_remaining_time", Name: "remaining_time"},
39 + },
40 + }
41 + licenseCertificateRemainingTimeChart = collectorapi.Chart{
42 + ID: "snmp_device_license_certificate_remaining_time",
43 + Title: "License certificate remaining time",
44 + Units: "seconds",
45 + Fam: "Licensing/Time",
46 + Ctx: "snmp.license.certificate_remaining_time",
47 + Priority: prioLicenseCertificateRemainingTime,
48 + SkipGaps: true,
49 + Dims: collectorapi.Dims{
50 + {ID: "snmp_device_license_certificate_remaining_time", Name: "remaining_time"},
51 + },
52 + }
53 + licenseGraceRemainingTimeChart = collectorapi.Chart{
54 + ID: "snmp_device_license_grace_remaining_time",
55 + Title: "License grace remaining time",
56 + Units: "seconds",
57 + Fam: "Licensing/Time",
58 + Ctx: "snmp.license.grace_remaining_time",
59 + Priority: prioLicenseGraceRemainingTime,
60 + SkipGaps: true,
61 + Dims: collectorapi.Dims{
62 + {ID: "snmp_device_license_grace_remaining_time", Name: "remaining_time"},
63 + },
64 + }
65 + licenseUsagePercentChart = collectorapi.Chart{
66 + ID: "snmp_device_license_usage_percent",
67 + Title: "License usage pressure",
68 + Units: "percentage",
69 + Fam: "Licensing/Usage",
70 + Ctx: "snmp.license.usage_percent",
71 + Priority: prioLicenseUsagePercent,
72 + Type: collectorapi.Area,
73 + SkipGaps: true,
74 + Dims: collectorapi.Dims{
75 + {ID: "snmp_device_license_usage_percent", Name: "usage_percent"},
76 + },
77 + }
78 + licenseStateChart = collectorapi.Chart{
79 + ID: "snmp_device_license_state",
80 + Title: "License state counts",
81 + Units: "licenses",
82 + Fam: "Licensing/State",
83 + Ctx: "snmp.license.state",
84 + Priority: prioLicenseState,
85 + Type: collectorapi.Stacked,
86 + SkipGaps: true,
87 + Dims: collectorapi.Dims{
88 + {ID: metricIDLicenseStateHealthy, Name: string(licenseStateBucketHealthy)},
89 + {ID: metricIDLicenseStateInformational, Name: string(licenseStateBucketInformational)},
90 + {ID: metricIDLicenseStateDegraded, Name: string(licenseStateBucketDegraded)},
91 + {ID: metricIDLicenseStateBroken, Name: string(licenseStateBucketBroken)},
92 + {ID: metricIDLicenseStateIgnored, Name: string(licenseStateBucketIgnored)},
93 + },
94 + }
95 +)
96 +
97 +func (c *Collector) addLicenseCharts(agg licenseAggregate) {
98 + if agg.hasRemainingTime {
99 + c.addLicenseChart(licenseRemainingTimeChart)
100 + }
101 + if agg.hasAuthRemaining {
102 + c.addLicenseChart(licenseAuthorizationRemainingTimeChart)
103 + }
104 + if agg.hasCertRemaining {
105 + c.addLicenseChart(licenseCertificateRemainingTimeChart)
106 + }
107 + if agg.hasGraceRemaining {
108 + c.addLicenseChart(licenseGraceRemainingTimeChart)
109 + }
110 + if agg.hasUsagePercent {
111 + c.addLicenseChart(licenseUsagePercentChart)
112 + }
113 + if agg.hasStateCounts {
114 + c.addLicenseChart(licenseStateChart)
115 + }
116 +}
117 +
118 +func (c *Collector) addLicenseChart(chart collectorapi.Chart) {
119 + if c.Charts().Get(chart.ID) != nil {
120 + return
121 + }
122 +
123 + ch := chart.Copy()
124 + labels := c.chartBaseLabels()
125 + labels["component"] = "licensing"
126 +
127 + for k, v := range labels {
128 + ch.Labels = append(ch.Labels, collectorapi.Label{Key: k, Value: v})
129 + }
130 +
131 + if err := c.Charts().Add(ch); err != nil {
132 + c.Warningf("failed to add license chart %q: %v", ch.ID, err)
133 + }
134 +}
src/go/plugin/go.d/collector/snmp/licensing_integration.go new
+56
@@ -0,0 +1,56 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "time"
7 +
8 + "github.com/netdata/netdata/go/plugins/pkg/funcapi"
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
10 +)
11 +
12 +type licensingIntegration struct {
13 + cache *licenseCache
14 +}
15 +
16 +func newLicensingIntegration() *licensingIntegration {
17 + return &licensingIntegration{cache: newLicenseCache()}
18 +}
19 +
20 +func (li *licensingIntegration) registerFunction(r *funcRouter) {
21 + if li == nil || r == nil {
22 + return
23 + }
24 + r.registerHandler(licensesMethodID, newFuncLicenses(li.cache))
25 +}
26 +
27 +func snmpMethods() []funcapi.MethodConfig {
28 + methods := snmpBaseMethods()
29 + return append(methods, licensesMethodConfig())
30 +}
31 +
32 +func (c *Collector) collectLicensing(mx map[string]int64, pms []*ddsnmp.ProfileMetrics) {
33 + if c.licensing == nil {
34 + return
35 + }
36 + c.licensing.collect(c, mx, pms)
37 +}
38 +
39 +func (li *licensingIntegration) collect(c *Collector, mx map[string]int64, pms []*ddsnmp.ProfileMetrics) {
40 + now := time.Now().UTC()
41 + rows := extractLicenseRows(pms, now)
42 +
43 + if li.cache != nil {
44 + li.cache.store(now, rows)
45 + }
46 +
47 + if len(rows) == 0 {
48 + return
49 + }
50 +
51 + agg := aggregateLicenseRows(rows, now)
52 + if !agg.empty() {
53 + c.addLicenseCharts(agg)
54 + }
55 + agg.writeTo(mx)
56 +}
src/go/plugin/go.d/collector/snmp/licensing_state.go new
+126
@@ -0,0 +1,126 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "strings"
7 + "time"
8 +)
9 +
10 +type licenseStateBucket string
11 +
12 +const (
13 + licenseStateBucketHealthy licenseStateBucket = "healthy"
14 + licenseStateBucketInformational licenseStateBucket = "informational"
15 + licenseStateBucketDegraded licenseStateBucket = "degraded"
16 + licenseStateBucketBroken licenseStateBucket = "broken"
17 + licenseStateBucketIgnored licenseStateBucket = "ignored"
18 +)
19 +
20 +func normalizeLicenseStateBucket(row licenseRow, now time.Time) licenseStateBucket {
21 + rawBucket, hasRawBucket := mapLicenseStateBucket(row.StateRaw)
22 + if hasRawBucket && rawBucket == licenseStateBucketIgnored {
23 + return licenseStateBucketIgnored
24 + }
25 +
26 + // Hard-fail conditions: a broken-timer or fully-consumed pool is broken
27 + // regardless of state. These come from FRESH metric values (the table
28 + // cache re-fetches symbol PDUs on every poll), so they cannot be stale.
29 + if licenseRowHasBrokenTimerOrUsage(row, now) {
30 + return licenseStateBucketBroken
31 + }
32 +
33 + if row.HasGraceTime {
34 + return licenseStateBucketDegraded
35 + }
36 +
37 + if hasRawBucket && rawBucket == licenseStateBucketInformational {
38 + return licenseStateBucketInformational
39 + }
40 +
41 + // Fresh severity wins over the cached raw state string. The raw state
42 + // string lives in a same-table metric_tag and the SNMP table cache
43 + // reuses row tags on cache hits, so a renewed-or-expired license could
44 + // continue to read the previous text for up to the cache TTL. Severity,
45 + // in contrast, is collected as a symbol whose value is re-fetched on
46 + // every poll, so it is always current.
47 + if row.HasState {
48 + return bucketFromSeverity(row.StateSeverity)
49 + }
50 +
51 + // No fresh severity → fall back to the raw vendor state classification.
52 + if hasRawBucket {
53 + return rawBucket
54 + }
55 +
56 + if row.HasExpiry && !row.IsPerpetual {
57 + return licenseStateBucketHealthy
58 + }
59 + if row.HasAuthorizationTime {
60 + return licenseStateBucketHealthy
61 + }
62 + if row.HasCertificateTime {
63 + return licenseStateBucketHealthy
64 + }
65 + if row.HasUsagePct || row.HasUsage || row.HasCapacity || row.HasAvailable {
66 + return licenseStateBucketHealthy
67 + }
68 + if row.IsPerpetual || row.IsUnlimited {
69 + return licenseStateBucketHealthy
70 + }
71 + if strings.TrimSpace(row.StateRaw) != "" {
72 + return licenseStateBucketDegraded
73 + }
74 + return licenseStateBucketIgnored
75 +}
76 +
77 +func licenseRowHasBrokenTimerOrUsage(row licenseRow, now time.Time) bool {
78 + if row.HasGraceTime && row.GraceExpiry <= now.Unix() {
79 + return true
80 + }
81 + if row.HasExpiry && !row.IsPerpetual && row.ExpiryTS <= now.Unix() {
82 + return true
83 + }
84 + if row.HasAuthorizationTime && row.AuthorizationExpiry <= now.Unix() {
85 + return true
86 + }
87 + if row.HasCertificateTime && row.CertificateExpiry <= now.Unix() {
88 + return true
89 + }
90 + return !row.IsUnlimited && row.HasUsagePct && row.UsagePercent >= 100
91 +}
92 +
93 +func bucketFromSeverity(sev int64) licenseStateBucket {
94 + switch sev {
95 + case 2:
96 + return licenseStateBucketBroken
97 + case 1:
98 + return licenseStateBucketDegraded
99 + default:
100 + return licenseStateBucketHealthy
101 + }
102 +}
103 +
104 +func mapLicenseStateBucket(raw string) (licenseStateBucket, bool) {
105 + if strings.TrimSpace(raw) == "" {
106 + return "", false
107 + }
108 +
109 + if licenseStateMatchesAny(raw, licenseStateIgnoredHints) {
110 + return licenseStateBucketIgnored, true
111 + }
112 + if licenseStateMatchesAny(raw, licenseStateBrokenHints) {
113 + return licenseStateBucketBroken, true
114 + }
115 + if licenseStateMatchesAny(raw, licenseStateInformationalHints) {
116 + return licenseStateBucketInformational, true
117 + }
118 + if licenseStateMatchesAny(raw, licenseStateDegradedHints) {
119 + return licenseStateBucketDegraded, true
120 + }
121 + if licenseStateMatchesAny(raw, licenseStateHealthyHints) {
122 + return licenseStateBucketHealthy, true
123 + }
124 +
125 + return "", false
126 +}
src/go/plugin/go.d/collector/snmp/licensing_state_match.go new
+142
@@ -0,0 +1,142 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "strings"
7 + "unicode"
8 +)
9 +
10 +var (
11 + licenseStateIgnoredHints = newLicenseStateHintSet(
12 + "ignored",
13 + "not_subscribed",
14 + "not subscribed",
15 + "not-applicable",
16 + "not_applicable",
17 + "not applicable",
18 + "none",
19 + "n/a",
20 + )
21 + licenseStateBrokenHints = newLicenseStateHintSet(
22 + "expired",
23 + "expired_in_use",
24 + "expired_not_in_use",
25 + "authorization_expired",
26 + "grace_period_expired",
27 + "evaluation_expired",
28 + "usage_count_consumed",
29 + "invalid",
30 + "invalid_tag",
31 + "unauthorized",
32 + "not_authorized",
33 + "not authorized",
34 + "out-of-compliance",
35 + "out_of_compliance",
36 + "not-associated",
37 + "not_associated",
38 + "not associated",
39 + "disabled",
40 + "deactivated",
41 + "failed",
42 + "error",
43 + "violation",
44 + "broken",
45 + )
46 + licenseStateDegradedHints = newLicenseStateHintSet(
47 + "about-to-expire",
48 + "about_to_expire",
49 + "about to expire",
50 + "warning",
51 + "degrade",
52 + "degraded",
53 + "grace",
54 + "overage",
55 + "partial",
56 + "unknown",
57 + )
58 + licenseStateInformationalHints = newLicenseStateHintSet(
59 + "evaluation",
60 + "evaluation_subscription",
61 + "evaluation subscription",
62 + "evaluation_period",
63 + "evaluation period",
64 + "eval",
65 + "trial",
66 + "initialized",
67 + "waiting",
68 + )
69 + licenseStateHealthyHints = newLicenseStateHintSet(
70 + "valid",
71 + "active",
72 + "authorized",
73 + "reserved_authorized",
74 + "reserved authorized",
75 + "compliant",
76 + "ok",
77 + "subscribed",
78 + "registered",
79 + "in_use",
80 + "in-use",
81 + "in use",
82 + "up-to-date",
83 + "up_to_date",
84 + "up to date",
85 + )
86 +)
87 +
88 +func newLicenseStateHintSet(hints ...string) map[string]struct{} {
89 + set := make(map[string]struct{}, len(hints))
90 + for _, hint := range hints {
91 + if normalized := normalizeLicenseStateText(hint); normalized != "" {
92 + set[normalized] = struct{}{}
93 + }
94 + }
95 + return set
96 +}
97 +
98 +func normalizeLicenseStateText(raw string) string {
99 + raw = strings.ToLower(strings.TrimSpace(raw))
100 + if raw == "" {
101 + return ""
102 + }
103 +
104 + normalized := strings.Map(func(r rune) rune {
105 + if unicode.IsLetter(r) || unicode.IsDigit(r) {
106 + return r
107 + }
108 + return ' '
109 + }, raw)
110 +
111 + return strings.Join(strings.Fields(normalized), " ")
112 +}
113 +
114 +func licenseStateMatchesAny(raw string, hints map[string]struct{}) bool {
115 + if len(hints) == 0 {
116 + return false
117 + }
118 +
119 + normalized := normalizeLicenseStateText(raw)
120 + if normalized == "" {
121 + return false
122 + }
123 +
124 + if _, ok := hints[normalized]; ok {
125 + return true
126 + }
127 +
128 + // Match normalized phrases on token boundaries so "authorization expired due to policy"
129 + // still matches the broken hint "authorization expired", without reintroducing false
130 + // positives such as "inactive" matching "active".
131 + padded := " " + normalized + " "
132 + for hint := range hints {
133 + if hint == "" || hint == normalized {
134 + continue
135 + }
136 + if strings.Contains(padded, " "+hint+" ") {
137 + return true
138 + }
139 + }
140 +
141 + return false
142 +}
src/go/plugin/go.d/collector/snmp/licensing_test.go new
+389
@@ -0,0 +1,389 @@
1 +// SPDX-License-Identifier: GPL-3.0-or-later
2 +
3 +package snmp
4 +
5 +import (
6 + "testing"
7 + "time"
8 +
9 + "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/ddsnmp"
10 +
11 + "github.com/stretchr/testify/assert"
12 + "github.com/stretchr/testify/require"
13 +)
14 +
15 +type typedLicenseRowOption func(*ddsnmp.LicenseRow)
16 +
17 +func profileWithRows(rows ...ddsnmp.LicenseRow) []*ddsnmp.ProfileMetrics {
18 + return []*ddsnmp.ProfileMetrics{{
19 + Source: "profiles/test-profile.yaml",
20 + LicenseRows: rows,
21 + }}
22 +}
23 +
24 +func typedLicenseRow(id, name string, opts ...typedLicenseRowOption) ddsnmp.LicenseRow {
25 + structuralID := ""
26 + if id != "" {
27 + structuralID = "test-profile|scalar|" + id
28 + }
29 + row := ddsnmp.LicenseRow{
30 + OriginProfileID: "test-profile",
31 + StructuralID: structuralID,
32 + ID: id,
33 + Name: name,
34 + }
35 + for _, opt := range opts {
36 + opt(&row)
37 + }
38 + return row
39 +}
40 +
41 +func withOriginProfileID(id string) typedLicenseRowOption {
42 + return func(row *ddsnmp.LicenseRow) {
43 + row.OriginProfileID = id
44 + }
45 +}
46 +
47 +func withStructuralID(id string) typedLicenseRowOption {
48 + return func(row *ddsnmp.LicenseRow) {
49 + row.StructuralID = id
50 + }
51 +}
52 +
53 +func withTable(oid, name, rowKey string) typedLicenseRowOption {
54 + return func(row *ddsnmp.LicenseRow) {
55 + row.TableOID = oid
56 + row.Table = name
57 + row.RowKey = rowKey
58 + if row.StructuralID == "" && row.OriginProfileID != "" && oid != "" && rowKey != "" {
59 + row.StructuralID = row.OriginProfileID + "|table|" + oid + "|" + rowKey
60 + }
61 + }
62 +}
63 +
64 +func withState(severity int64, raw string) typedLicenseRowOption {
65 + return func(row *ddsnmp.LicenseRow) {
66 + row.State.Has = true
67 + row.State.Severity = severity
68 + row.State.Raw = raw
69 + }
70 +}
71 +
72 +func withRawState(raw string) typedLicenseRowOption {
73 + return func(row *ddsnmp.LicenseRow) {
74 + row.State.Raw = raw
75 + }
76 +}
77 +
78 +func withExpiry(timestamp int64) typedLicenseRowOption {
79 + return func(row *ddsnmp.LicenseRow) {
80 + row.Expiry.Has = true
81 + row.Expiry.Timestamp = timestamp
82 + }
83 +}
84 +
85 +func withExpiryRemaining(seconds int64) typedLicenseRowOption {
86 + return func(row *ddsnmp.LicenseRow) {
87 + row.Expiry.Has = true
88 + row.Expiry.RemainingSeconds = seconds
89 + }
90 +}
91 +
92 +func withAuthorizationRemaining(seconds int64) typedLicenseRowOption {
93 + return func(row *ddsnmp.LicenseRow) {
94 + row.Authorization.Has = true
95 + row.Authorization.RemainingSeconds = seconds
96 + }
97 +}
98 +
99 +func withCertificateRemaining(seconds int64) typedLicenseRowOption {
100 + return func(row *ddsnmp.LicenseRow) {
101 + row.Certificate.Has = true
102 + row.Certificate.RemainingSeconds = seconds
103 + }
104 +}
105 +
106 +func withGraceRemaining(seconds int64) typedLicenseRowOption {
107 + return func(row *ddsnmp.LicenseRow) {
108 + row.Grace.Has = true
109 + row.Grace.RemainingSeconds = seconds
110 + }
111 +}
112 +
113 +func withExpirySource(source string) typedLicenseRowOption {
114 + return func(row *ddsnmp.LicenseRow) {
115 + row.Expiry.SourceOID = source
116 + }
117 +}
118 +
119 +func withUsage(used int64) typedLicenseRowOption {
120 + return func(row *ddsnmp.LicenseRow) {
121 + row.Usage.HasUsed = true
122 + row.Usage.Used = used
123 + }
124 +}
125 +
126 +func withCapacity(capacity int64) typedLicenseRowOption {
127 + return func(row *ddsnmp.LicenseRow) {
128 + row.Usage.HasCapacity = true
129 + row.Usage.Capacity = capacity
130 + }
131 +}
132 +
133 +func withAvailable(available int64) typedLicenseRowOption {
134 + return func(row *ddsnmp.LicenseRow) {
135 + row.Usage.HasAvailable = true
136 + row.Usage.Available = available
137 + }
138 +}
139 +
140 +func withUsagePercent(percent int64) typedLicenseRowOption {
141 + return func(row *ddsnmp.LicenseRow) {
142 + row.Usage.HasPercent = true
143 + row.Usage.Percent = percent
144 + }
145 +}
146 +
147 +func withPerpetual() typedLicenseRowOption {
148 + return func(row *ddsnmp.LicenseRow) {
149 + row.IsPerpetual = true
150 + }
151 +}
152 +
153 +func withUnlimited() typedLicenseRowOption {
154 + return func(row *ddsnmp.LicenseRow) {
155 + row.IsUnlimited = true
156 + }
157 +}
158 +
159 +func TestExtractLicenseRows_FromTypedRows(t *testing.T) {
160 + now := time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC)
161 + expiry := now.Add(48 * time.Hour).Unix()
162 +
163 + tests := map[string]struct {
164 + rows []ddsnmp.LicenseRow
165 + assert func(t *testing.T, rows []licenseRow)
166 + }{
167 + "copies identity descriptors state timer and usage": {
168 + rows: []ddsnmp.LicenseRow{
169 + typedLicenseRow("base", "Base Firewall",
170 + withState(0, "active"),
171 + withExpiry(expiry),
172 + withUsage(75),
173 + withCapacity(100),
174 + ),
175 + },
176 + assert: func(t *testing.T, rows []licenseRow) {
177 + require.Len(t, rows, 1)
178 + row := rows[0]
179 + assert.Equal(t, "base", row.ID)
180 + assert.Equal(t, "Base Firewall", row.Name)
181 + assert.Equal(t, "test-profile", row.Source)
182 + assert.True(t, row.HasState)
183 + assert.EqualValues(t, 0, row.StateSeverity)
184 + assert.True(t, row.HasExpiry)
185 + assert.EqualValues(t, expiry, row.ExpiryTS)
186 + assert.True(t, row.HasUsage)
187 + assert.EqualValues(t, 75, row.Usage)
188 + assert.True(t, row.HasCapacity)
189 + assert.EqualValues(t, 100, row.Capacity)
190 + assert.True(t, row.HasUsagePct)
191 + assert.InDelta(t, 75.0, row.UsagePercent, 0.001)
192 + assert.Equal(t, licenseStateBucketHealthy, row.StateBucket)
193 + },
194 + },
195 + "derives usage from capacity minus available": {
196 + rows: []ddsnmp.LicenseRow{
197 + typedLicenseRow("pool", "Connection pool",
198 + withCapacity(100),
199 + withAvailable(25),
200 + ),
201 + },
202 + assert: func(t *testing.T, rows []licenseRow) {
203 + require.Len(t, rows, 1)
204 + assert.True(t, rows[0].HasUsage)
205 + assert.EqualValues(t, 75, rows[0].Usage)
206 + assert.True(t, rows[0].HasUsagePct)
207 + assert.InDelta(t, 75.0, rows[0].UsagePercent, 0.001)
208 + },
209 + },
210 + "rebases remaining timers on collect time": {
211 + rows: []ddsnmp.LicenseRow{
212 + typedLicenseRow("auth", "Auth", withAuthorizationRemaining(3600)),
213 + typedLicenseRow("cert", "Cert", withCertificateRemaining(7200)),
214 + typedLicenseRow("grace", "Grace", withGraceRemaining(1800)),
215 + typedLicenseRow("sub", "Sub", withExpiryRemaining(600)),
216 + },
217 + assert: func(t *testing.T, rows []licenseRow) {
218 + require.Len(t, rows, 4)
219 + byID := make(map[string]licenseRow, len(rows))
220 + for _, row := range rows {
221 + byID[row.ID] = row
222 + }
223 + assert.EqualValues(t, now.Unix()+3600, byID["auth"].AuthorizationExpiry)
224 + assert.EqualValues(t, now.Unix()+7200, byID["cert"].CertificateExpiry)
225 + assert.EqualValues(t, now.Unix()+1800, byID["grace"].GraceExpiry)
226 + assert.EqualValues(t, now.Unix()+600, byID["sub"].ExpiryTS)
227 + },
228 + },
229 + "drops rows without identity or signal": {
230 + rows: []ddsnmp.LicenseRow{
231 + typedLicenseRow("present", "Present", withState(0, "")),
232 + typedLicenseRow("", "No ID", withState(0, "")),
233 + typedLicenseRow("stub", "No signal"),
234 + },
235 + assert: func(t *testing.T, rows []licenseRow) {
236 + require.Len(t, rows, 1)
237 + assert.Equal(t, "present", rows[0].ID)
238 + },
239 + },
240 + "uses table OID as the internal table identity": {
241 + rows: []ddsnmp.LicenseRow{
242 + typedLicenseRow("FortiCare", "FortiCare",
243 + withTable("1.3.6.1.4.1.12356.101.4.6.3.1", "fgLicContractTable", "1"),
244 + withCapacity(100),
245 + ),
246 + typedLicenseRow("FortiCare", "FortiCare",
247 + withTable("1.3.6.1.4.1.12356.101.4.6.4.1", "fgLicVersionTable", "1"),
248 + withCapacity(200),
249 + ),
250 + },
251 + assert: func(t *testing.T, rows []licenseRow) {
252 + require.Len(t, rows, 2)
253 + assert.NotEqual(t, rows[0].Table, rows[1].Table)
254 + caps := []int64{rows[0].Capacity, rows[1].Capacity}
255 + assert.Contains(t, caps, int64(100))
256 + assert.Contains(t, caps, int64(200))
257 + },
258 + },
259 + "ignores private metrics": {
260 + rows: nil,
261 + assert: func(t *testing.T, _ []licenseRow) {
262 + pm := &ddsnmp.ProfileMetrics{
263 + Source: "profiles/test-profile.yaml",
264 + HiddenMetrics: []ddsnmp.Metric{{
265 + Name: "_private_metric",
266 + Value: 0,
267 + Tags: map[string]string{"component": "private"},
268 + }},
269 + }
270 + assert.Empty(t, extractLicenseRows([]*ddsnmp.ProfileMetrics{pm}, now))
271 + },
272 + },
273 + }
274 +
275 + for name, tc := range tests {
276 + t.Run(name, func(t *testing.T) {
277 + rows := extractLicenseRows(profileWithRows(tc.rows...), now)
278 + tc.assert(t, rows)
279 + })
280 + }
281 +}
282 +
283 +func TestNormalizeLicenseStateBucket(t *testing.T) {
284 + now := time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC)
285 +
286 + tests := map[string]struct {
287 + row ddsnmp.LicenseRow
288 + want licenseStateBucket
289 + }{
290 + "fresh severity recovers from stale broken raw state": {
291 + row: typedLicenseRow("renewed", "Renewed", withState(0, "expired")),
292 + want: licenseStateBucketHealthy,
293 + },
294 + "ignored raw state suppresses severity zero": {
295 + row: typedLicenseRow("inactive", "Inactive", withState(0, "none")),
296 + want: licenseStateBucketIgnored,
297 + },
298 + "eval raw state becomes informational instead of degraded": {
299 + row: typedLicenseRow("eval", "Evaluation", withState(1, "Evaluation")),
300 + want: licenseStateBucketInformational,
301 + },
302 + "raw broken state is used when no fresh severity exists": {
303 + row: typedLicenseRow("raw-broken", "Raw Broken", withRawState("expired"), withUsage(1)),
304 + want: licenseStateBucketBroken,
305 + },
306 + "raw degraded state is used when no fresh severity exists": {
307 + row: typedLicenseRow("raw-degraded", "Raw Degraded", withRawState("degraded"), withUsage(1)),
308 + want: licenseStateBucketDegraded,
309 + },
310 + "expired timer forces broken over valid raw state": {
311 + row: typedLicenseRow("expired", "Expired", withRawState("valid"), withExpiry(now.Add(-time.Hour).Unix())),
312 + want: licenseStateBucketBroken,
313 + },
314 + "grace timer without expiry is degraded": {
315 + row: typedLicenseRow("grace", "Grace", withGraceRemaining(3600)),
316 + want: licenseStateBucketDegraded,
317 + },
318 + "fully consumed usage is broken": {
319 + row: typedLicenseRow("pool", "Pool", withUsagePercent(100)),
320 + want: licenseStateBucketBroken,
321 + },
322 + }
323 +
324 + for name, tc := range tests {
325 + t.Run(name, func(t *testing.T) {
326 + rows := extractLicenseRows(profileWithRows(tc.row), now)
327 + require.Len(t, rows, 1)
328 + assert.Equal(t, tc.want, rows[0].StateBucket)
329 + })
330 + }
331 +}
332 +
333 +func TestExtractLicenseRows_CheckPointPublicFixtureValues(t *testing.T) {
334 + now := time.Date(2026, 4, 9, 12, 0, 0, 0, time.UTC)
335 + tests := map[string]struct {
336 + rows []ddsnmp.LicenseRow
337 + }{
338 + "public Check Point sample values": {
339 + rows: []ddsnmp.LicenseRow{
340 + typedLicenseRow("0", "Firewall", withState(2, "Not Entitled")),
341 + typedLicenseRow("4", "Application Ctrl", withState(1, "Evaluation"), withExpiry(1619246913)),
342 + typedLicenseRow("2", "IPS", withState(1, "Evaluation"), withExpiry(1619246941)),
343 + },
344 + },
345 + }
346 +
347 + for name, tc := range tests {
348 + t.Run(name, func(t *testing.T) {
349 + rows := extractLicenseRows(profileWithRows(tc.rows...), now)
350 + require.Len(t, rows, 3)
351 +
352 + for _, row := range rows {
353 + switch row.ID {
354 + case "0":
355 + assert.Equal(t, licenseStateBucketBroken, row.StateBucket, "Not Entitled -> broken")
356 + assert.False(t, row.HasExpiry)
357 + case "4":
358 + assert.Equal(t, licenseStateBucketBroken, row.StateBucket, "expired Evaluation -> broken")
359 + assert.True(t, row.HasExpiry)
360 + assert.EqualValues(t, 1619246913, row.ExpiryTS)
361 + case "2":
362 + assert.Equal(t, licenseStateBucketBroken, row.StateBucket, "expired Evaluation -> broken")
363 + assert.True(t, row.HasExpiry)
364 + assert.EqualValues(t, 1619246941, row.ExpiryTS)
365 + default:
366 + t.Fatalf("unexpected row id %q", row.ID)
367 + }
368 + }
369 + })
370 + }
371 +}
372 +
373 +func TestLicenseRowUniqueKeyHandlesEmbeddedNULs(t *testing.T) {
374 + tests := map[string]struct {
375 + left licenseRow
376 + right licenseRow
377 + }{
378 + "embedded NULs stay unambiguous": {
379 + left: licenseRow{Source: "vendor", Table: "table", ID: "row\x00suffix"},
380 + right: licenseRow{Source: "vendor", Table: "table\x00row", ID: "suffix"},
381 + },
382 + }
383 +
384 + for name, tc := range tests {
385 + t.Run(name, func(t *testing.T) {
386 + assert.NotEqual(t, licenseRowUniqueKey(tc.left), licenseRowUniqueKey(tc.right))
387 + })
388 + }
389 +}
src/go/plugin/go.d/collector/snmp/metadata.yaml
+186 -3
@@ -86,6 +86,10 @@ modules:
86 - f5
87 - big-ip
88 - fireeye
89 + - bluecoat
90 + - proxysg
91 + - sgos
92 + - symantec
93 - fortinet
94 - fortigate
95 - fortiswitch
@@ -174,6 +178,8 @@ modules:
178 - **Automatic vendor/model detection**: Devices are matched to the right profile using selectors such as `sysObjectID` and `sysDescr`.
179 - **ICMP ping**: Optional round-trip latency monitoring alongside SNMP, with a `ping_only` mode available.
180 - **SNMP v1, v2c, and v3 support**: Fully implemented via the [gosnmp](https://github.com/gosnmp/gosnmp) library.
181 + - **Shared device-level licensing metrics for supported profiles**: When a profile exposes license telemetry, Netdata emits compact per-device licensing charts for earliest expiry, license-state counts (`healthy`, `informational`, `degraded`, `broken`, `ignored`), and highest usage pressure. Supported profile coverage includes Check Point licensing state and per-blade expiry, Fortinet FortiGate contract/service/account expirations, Cisco traditional licensing end-date/remaining-time/state/usage telemetry, Cisco Smart Licensing authorization, certificate, evaluation, and state telemetry, Sophos Firewall subscription state and per-license expiry telemetry, Blue Coat ProxySG application/feature/component expiry, expire-type, and state telemetry, and basic MikroTik RouterOS upgrade-entitlement telemetry. For MikroTik, epoch-like placeholder `mtxrLicUpgrUntil` values are ignored instead of treated as real expired licenses.
182 + - **Interactive licensing drill-down**: The `snmp:licenses` function follows the existing SNMP function pattern and shows normalized licensing rows for the selected SNMP job/device using cached collector data.
183
184
185 **Built-in profiles for major vendors:**
@@ -181,7 +187,7 @@ modules:
187 | Category | Vendors |
188 |----------|---------|
189 | Switches & Routers | Cisco (Catalyst, Nexus, ASR, ISR), Arista, Juniper, HP/HPE, Dell, Extreme |
184 - | Firewalls | Palo Alto, Fortinet FortiGate, Cisco ASA, Checkpoint, SonicWall |
190 + | Firewalls | Palo Alto, Fortinet FortiGate, Cisco ASA, Checkpoint, SonicWall, Sophos |
191 | Wireless | Aruba, Cisco WLC, Ubiquiti, Alcatel-Lucent |
192 | Load Balancers | F5 BIG-IP, Citrix NetScaler, A10 Thunder |
193 | Infrastructure | APC UPS/PDU, Dell servers, plus standard MIBs (BGP, OSPF, TCP/UDP) |
@@ -518,7 +524,35 @@ modules:
524 - <<: *snmp_v3_job
525 name: switch3
526 hostname: 192.0.2.3
521 - alerts: []
527 + alerts:
528 + - name: snmp_license_expiring
529 + metric: snmp.license.remaining_time
530 + info: "The earliest monitored SNMP license or subscription on this device is close to expiration."
531 + link: https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf
532 + - name: snmp_license_authorization_expiring
533 + metric: snmp.license.authorization_remaining_time
534 + info: "The license authorization timer on this device is close to expiration."
535 + link: https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf
536 + - name: snmp_license_certificate_expiring
537 + metric: snmp.license.certificate_remaining_time
538 + info: "The license certificate timer on this device is close to expiration."
539 + link: https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf
540 + - name: snmp_license_grace_period_ending
541 + metric: snmp.license.grace_remaining_time
542 + info: "The licensing grace or evaluation period on this device is ending or already expired."
543 + link: https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf
544 + - name: snmp_license_state_warning
545 + metric: snmp.license.state
546 + info: "One or more monitored licenses on this device are degraded, in grace, or otherwise in warning state."
547 + link: https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf
548 + - name: snmp_license_state_critical
549 + metric: snmp.license.state
550 + info: "One or more monitored licenses on this device are expired, invalid, unauthorized, or otherwise in critical state."
551 + link: https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf
552 + - name: snmp_license_usage_high
553 + metric: snmp.license.usage_percent
554 + info: "The most constrained monitored license pool on this device is nearing exhaustion."
555 + link: https://github.com/netdata/netdata/blob/master/src/health/health.d/snmp.conf
556 functions:
557 description: |
558 This collector exposes real-time functions for interactive troubleshooting in the Live tab.
@@ -778,6 +812,84 @@ modules:
812 Exposes discovered device identifiers, interface/port identifiers, and management addresses only:<br/>• No packet payloads or authentication credentials are exposed<br/>• No device configuration details are exposed
813 availability: |
814 Available when:<br/>• The collector has completed at least one successful topology refresh cycle<br/>• LLDP/CDP topology data is present in cache from the last successful topology refresh<br/>• Returns HTTP 503 if topology cache is not ready yet
815 +
816 + - id: licenses
817 + name: Licenses
818 + description: |
819 + Provides normalized licensing rows for the selected SNMP device.
820 +
821 + This function reads the collector's cached licensing rows and shows one row per normalized license entry. It is the drill-down view behind the device-level licensing charts: expiry timers, authorization/certificate/grace timers, usage/capacity, raw vendor state, normalized state bucket, and operational impact.
822 +
823 + It follows the same pattern as the SNMP `interfaces` function:
824 + - the function itself is global for the SNMP collector
825 + - the Live tab targets the selected SNMP job/device automatically using the standard SNMP function context
826 + - the returned rows belong only to that selected device
827 +
828 + Use cases:
829 + - See which exact license rows make the device show degraded or broken counts
830 + - Inspect expiry and grace timers for subscriptions, support, and smart-licensing signals
831 + - Review usage/capacity for finite license pools on the selected device
832 +
833 + Data is sourced from the last successful SNMP collection. No extra SNMP requests are triggered when calling this function.
834 + parameters: []
835 + returns:
836 + description: Normalized licensing rows for the selected SNMP device. Each row represents one cached normalized licensing entry from the collector.
837 + columns:
838 + - name: License
839 + type: string
840 + unit: ""
841 + description: Human-readable license row name, or the normalized license identifier when no name exists.
842 + - name: ID
843 + type: string
844 + unit: ""
845 + visibility: hidden
846 + description: Stable row identifier used by the UI to track one normalized licensing row across updates.
847 + - name: Bucket
848 + type: string
849 + unit: ""
850 + description: "Normalized health bucket for the row: healthy, informational, degraded, broken, or ignored."
851 + - name: State
852 + type: string
853 + unit: ""
854 + description: Raw vendor licensing state when the device exposes one.
855 + - name: Component
856 + type: string
857 + unit: ""
858 + description: Normalized component or area associated with the license row.
859 + - name: Type
860 + type: string
861 + unit: ""
862 + description: Normalized license type such as subscription, certificate, authorization, evaluation, or usage pool.
863 + - name: Remaining
864 + type: duration
865 + unit: "milliseconds"
866 + description: Time remaining until the row's primary expiry, when applicable.
867 + - name: Expiry
868 + type: timestamp
869 + unit: ""
870 + description: Absolute expiry time for the row, when known.
871 + - name: Usage
872 + type: integer
873 + unit: "licenses"
874 + description: Used license units for finite pools.
875 + - name: Capacity
876 + type: integer
877 + unit: "licenses"
878 + description: Total capacity for finite license pools.
879 + - name: Usage %
880 + type: float
881 + unit: "percentage"
882 + description: Usage pressure for finite license pools.
883 + - name: Impact
884 + type: string
885 + unit: ""
886 + description: Operational impact text when the vendor or profile provides it.
887 + performance: |
888 + Uses cached SNMP data only, no additional SNMP requests are triggered:<br/>• Responses are instantaneous from memory cache<br/>• Large devices with many licensing rows may return many rows
889 + security: |
890 + Exposes licensing names, states, timers, counts, and impact notes only:<br/>• No credentials or secrets are exposed<br/>• No device configuration is modified
891 + availability: |
892 + Available when:<br/>• The collector has completed at least one licensing-aware data collection cycle that produced licensing rows<br/>• Licensing data is cached from the last successful SNMP collection<br/>• Returns HTTP 503 if cache is not ready yet or the device/profile exposes no licensing rows
893 metrics:
894 folding:
895 title: Metrics
@@ -785,6 +897,31 @@ modules:
897 description: |
898 Metrics and charts are **defined by the matched SNMP profile(s)** at runtime. They differ by vendor/model/OS and may include, for example, interface counters, optics, CPU/memory, temperature, VLANs, and more. Use the **Metrics** tab on the device’s dashboard to see exactly what is collected for that device.
899
900 + Supported licensing profiles also emit a small set of shared **device-level licensing contexts**:
901 +
902 + - `snmp.license.remaining_time`: earliest remaining time to expiry across monitored licenses and subscriptions on the device
903 + - `snmp.license.authorization_remaining_time`: earliest remaining time for license authorization timers
904 + - `snmp.license.certificate_remaining_time`: earliest remaining time for licensing certificate timers
905 + - `snmp.license.grace_remaining_time`: earliest remaining time for grace or evaluation timers
906 + - `snmp.license.usage_percent`: highest license pool pressure across finite usage pools on the device
907 + - `snmp.license.state`: count of licensing rows on the device by normalized state bucket (`healthy`, `informational`, `degraded`, `broken`, `ignored`)
908 +
909 + The licensing charts are intentionally aggregated to keep one clean device view:
910 +
911 + - Netdata reports the **earliest** expiry-related deadline on the device.
912 + - Netdata reports how many licensing rows are **healthy**, **informational**, **degraded**, **broken**, and **ignored** on the device.
913 + - Netdata reports the **highest** usage pressure across finite license pools on the device.
914 +
915 + Licensing support is intentionally conditional:
916 +
917 + - licensing charts appear only for devices whose matched SNMP profile exposes licensing telemetry
918 + - not every supported device exposes all licensing dimensions
919 + - `remaining_time` and related time charts appear only when the device exposes expiry-like timers
920 + - `usage_percent` appears only when the device exposes finite usage and capacity data
921 + - `snmp.license.state` appears only when the device exposes enough state/compliance/validity data to normalize rows
922 +
923 + Default alerts from `src/health/health.d/snmp.conf` use `30d/7d` for expiry-related timers, `7d/0d` for grace timers, and `80/95` for usage pressure. You can override them like any other Netdata health configuration.
924 +
925 :::tip
926
927 To understand the structure of these profiles (metrics, tags, virtual metrics, etc.), see **[SNMP Profile Format](/src/go/plugin/go.d/collector/snmp/profile-format.md)**.
@@ -793,7 +930,53 @@ modules:
930
931 If `ping.enabled` is true, ICMP latency/packet-loss charts are also provided (or exclusively, when `ping_only: true`).
932 availability: []
796 - scopes: []
933 + scopes:
934 + - name: device licensing
935 + description: Shared device-level licensing health metrics emitted when the matched SNMP profile provides licensing telemetry. Supported profile coverage includes Check Point licensing state and per-blade expiry, Fortinet FortiGate contract/service/account expirations, Cisco traditional licensing end-date/remaining-time/state/usage telemetry, Cisco Smart Licensing authorization, certificate, evaluation, and state telemetry, Sophos Firewall subscription state and per-license expiry telemetry, Blue Coat ProxySG application/feature/component expiry, expire-type, and state telemetry, and basic MikroTik RouterOS upgrade-entitlement telemetry. MikroTik support is intentionally limited to the RouterOS upgrade-entitlement fields exposed by SNMP, and epoch-like placeholder `mtxrLicUpgrUntil` values are ignored.
936 + labels:
937 + - name: component
938 + description: Always `licensing` for the shared SNMP licensing charts.
939 + metrics:
940 + - name: snmp.license.remaining_time
941 + description: Earliest remaining time to expiry across monitored licenses and subscriptions on the device.
942 + unit: seconds
943 + chart_type: line
944 + dimensions:
945 + - name: remaining_time
946 + - name: snmp.license.authorization_remaining_time
947 + description: Earliest remaining time for licensing authorization timers on the device.
948 + unit: seconds
949 + chart_type: line
950 + dimensions:
951 + - name: remaining_time
952 + - name: snmp.license.certificate_remaining_time
953 + description: Earliest remaining time for licensing certificate timers on the device.
954 + unit: seconds
955 + chart_type: line
956 + dimensions:
957 + - name: remaining_time
958 + - name: snmp.license.grace_remaining_time
959 + description: Earliest remaining time for licensing grace or evaluation timers on the device.
960 + unit: seconds
961 + chart_type: line
962 + dimensions:
963 + - name: remaining_time
964 + - name: snmp.license.usage_percent
965 + description: Highest usage pressure across finite licensing pools on the device.
966 + unit: percentage
967 + chart_type: area
968 + dimensions:
969 + - name: usage_percent
970 + - name: snmp.license.state
971 + description: "Count of licensing rows on the device by normalized state bucket: healthy, informational, degraded, broken, and ignored."
972 + unit: licenses
973 + chart_type: stacked
974 + dimensions:
975 + - name: healthy
976 + - name: informational
977 + - name: degraded
978 + - name: broken
979 + - name: ignored
980 troubleshooting:
981 problems:
982 list:
src/go/plugin/go.d/collector/snmp/profile-format.md
+138
@@ -2184,3 +2184,141 @@ What this does
2184 - Builds a **single total chart** combining multiple related packet counters.
2185 - Each `as` becomes a **dimension** (`in_ucast`, `out_ucast`, `in_mcast`, …).
2186 - No `per_row`/`group_by` → totals aggregated across all interfaces.
2187 +
2188 +## Licensing rows
2189 +
2190 +The SNMP collector ships a **shared device-level licensing pipeline** that
2191 +turns vendor-specific licensing telemetry into six common contexts
2192 +(`snmp.license.remaining_time`, `snmp.license.authorization_remaining_time`,
2193 +`snmp.license.certificate_remaining_time`, `snmp.license.grace_remaining_time`,
2194 +`snmp.license.usage_percent`, `snmp.license.state`) plus an interactive
2195 +`snmp:licenses` drill-down function. Profiles describe licensing telemetry in
2196 +a top-level `licensing:` section. The collector emits typed license rows from
2197 +that section; regular `metrics:` rows are not used as a licensing transport.
2198 +
2199 +### Authoring contract
2200 +
2201 +A licensing row describes one vendor license, entitlement, contract, or
2202 +license pool. A row may be table-backed or scalar-backed:
2203 +
2204 +- A table-backed row declares `table:` and produces one typed license row per
2205 + SNMP table row.
2206 +- A scalar-backed row omits `table:` and produces one typed license row for the
2207 + scalar values named in the row.
2208 +- Scalar-backed rows that use only literal `value:` fields must declare an
2209 + explicit stable `id:` because there is no signal OID to use as structural
2210 + identity.
2211 +
2212 +Each row has:
2213 +
2214 +- `identity:` fields used by the drill-down: `id`, `name`, `feature`,
2215 + `component`.
2216 +- `descriptors:` fields: `type`, `impact`, `perpetual`, `unlimited`.
2217 +- `state:` for a normalized state severity (`0` healthy, `1` degraded, `2`
2218 + broken) plus the raw vendor value.
2219 +- `signals:` for timers and usage:
2220 + - `expiry.timestamp` / `expiry.remaining`
2221 + - `authorization.timestamp` / `authorization.remaining`
2222 + - `certificate.timestamp` / `certificate.remaining`
2223 + - `grace.timestamp` / `grace.remaining`
2224 + - `usage.used`, `usage.capacity`, `usage.available`, `usage.percent`
2225 +
2226 +Example table-backed row:
2227 +
2228 +```yaml
2229 +licensing:
2230 + - id: licensing_blades
2231 + MIB: CHECKPOINT-MIB
2232 + table:
2233 + OID: 1.3.6.1.4.1.2620.1.6.18.1
2234 + name: licensingTable
2235 + identity:
2236 + id: { OID: 1.3.6.1.4.1.2620.1.6.18.1.1.2, name: licensingID }
2237 + name: { OID: 1.3.6.1.4.1.2620.1.6.18.1.1.4, name: licensingBladeName }
2238 + component: { value: blade }
2239 + descriptors:
2240 + type: { value: subscription }
2241 + state:
2242 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.5
2243 + name: licensingState
2244 + mapping:
2245 + valid: "0"
2246 + "about-to-expire": "1"
2247 + expired: "2"
2248 + signals:
2249 + expiry:
2250 + timestamp:
2251 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.6
2252 + name: licensingExpirationDate
2253 + sentinel: [timer_u32_max]
2254 + usage:
2255 + used: { OID: 1.3.6.1.4.1.2620.1.6.18.1.1.10, name: licensingUsedQuota }
2256 + capacity: { OID: 1.3.6.1.4.1.2620.1.6.18.1.1.9, name: licensingTotalQuota }
2257 +```
2258 +
2259 +Example scalar-backed row:
2260 +
2261 +```yaml
2262 +licensing:
2263 + - id: routeros_upgrade
2264 + MIB: MIKROTIK-MIB
2265 + identity:
2266 + id: { value: routeros_upgrade }
2267 + name: { value: RouterOS upgrade entitlement }
2268 + component: { value: routeros }
2269 + descriptors:
2270 + type: { value: upgrade_entitlement }
2271 + signals:
2272 + expiry:
2273 + timestamp:
2274 + OID: 1.3.6.1.4.1.14988.1.1.4.2.0
2275 + name: mtxrLicUpgrUntil
2276 + format: snmp_dateandtime
2277 + sentinel: [timer_pre_1971]
2278 +```
2279 +
2280 +### Parsing vendor expiry dates
2281 +
2282 +The value-processor format mechanism handles licensing expiry dates directly.
2283 +On each poll, the table collector re-fetches value columns even on table-cache
2284 +hits, so expiry values are decoded from the current poll's PDU instead of from
2285 +cached row metadata.
2286 +
2287 +This does **not** disable the generic SNMP table cache for the surrounding
2288 +table. Same-table `metric_tags` can still come from cached row metadata on
2289 +cache hits. For live licensing state, prefer symbol-based severity and
2290 +timestamp values over same-table text tags whenever the device exposes both.
2291 +Three options are available:
2292 +
2293 +- **No format** — for vendors that publish expiry as a plain integer unix
2294 + epoch in `Gauge32` / `Counter32` / `Unsigned32`. The numeric value
2295 + processor reads it directly. Check Point's `licensingExpirationDate` is one
2296 + example.
2297 +- `format: snmp_dateandtime` — for SNMPv2-TC `DateAndTime` octet strings (8
2298 + or 11 byte fixed binary). Used by vendors like Blue Coat ProxySG and Cisco
2299 + `CISCO-LICENSE-MGMT-MIB`.
2300 +- `format: text_date` — for textual date strings (e.g., `2026-12-31`,
2301 + `Mon Jan 2 2030`, epoch seconds/milliseconds embedded as text). Accepts
2302 + the same layouts as the licensing pipeline's internal date parser. Used
2303 + by Fortinet's `DisplayString` expiry columns.
2304 +
2305 +The decoded unix timestamp is stored in the typed timer. The licensing
2306 +projection can drop known "no expiry" sentinels before the consumer sees them.
2307 +Supported sentinel policies are:
2308 +
2309 +- `timer_zero_or_negative`
2310 +- `timer_u32_max`
2311 +- `timer_pre_1971`
2312 +
2313 +### Identity and indexes
2314 +
2315 +For table rows, the collector keeps structural identity from the profile,
2316 +table OID, and SNMP row index. Human-readable identity fields are for display
2317 +and grouping in the drill-down. For `not-accessible` index objects, derive the
2318 +identity from the row index:
2319 +
2320 +```yaml
2321 +identity:
2322 + id:
2323 + index: 1
2324 +```
src/go/plugin/go.d/collector/snmp/profile_sets.go
+1 -1
@@ -23,7 +23,7 @@ func (c *Collector) setupProfiles(si *snmputils.SysInfo) []*ddsnmp.Profile {
23 matchedProfiles := resolved.Profiles()
24 c.logMatchedProfiles(matchedProfiles, si.SysObjectID)
25
26 - return resolved.Project(ddsnmp.ConsumerMetrics).Profiles()
26 + return resolved.Project(ddsnmp.ConsumerMetrics, ddsnmp.ConsumerLicensing).Profiles()
27 }
28
29 func (c *Collector) logMatchedProfiles(profiles []*ddsnmp.Profile, sysObjectID string) {
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_cisco-licensing-smart.yaml new
+155
@@ -0,0 +1,155 @@
1 +# Cisco Smart Licensing mixin.
2 +
3 +licensing:
4 + - MIB: CISCO-SMART-LIC-MIB
5 + id: smart_registration
6 + identity:
7 + id:
8 + value: smart_registration
9 + name:
10 + value: Smart Licensing registration
11 + component:
12 + value: smart_licensing
13 + descriptors:
14 + type:
15 + value: registration
16 + impact:
17 + value: Smart Licensing registration state on the device
18 + state:
19 + OID: 1.3.6.1.4.1.9.9.831.0.6.1.0
20 + name: ciscoSlaRegistrationStatus
21 + mapping:
22 + 1: "1"
23 + 2: "0"
24 + 3: "2"
25 + 4: "1"
26 + 5: "0"
27 +
28 + - MIB: CISCO-SMART-LIC-MIB
29 + id: smart_authorization
30 + identity:
31 + id:
32 + value: smart_authorization
33 + name:
34 + value: Smart Licensing authorization
35 + component:
36 + value: smart_licensing
37 + descriptors:
38 + type:
39 + value: authorization
40 + impact:
41 + value: Overall Smart Licensing compliance or authorization state on the device
42 + state:
43 + OID: 1.3.6.1.4.1.9.9.831.0.7.2.0
44 + name: ciscoSlaAuthComplianceStatus
45 + mapping:
46 + Authorized: "0"
47 + AUTHORIZED: "0"
48 + In Compliance: "0"
49 + IN COMPLIANCE: "0"
50 + Out of Compliance: "2"
51 + OUT OF COMPLIANCE: "2"
52 + Evaluation Mode: "1"
53 + EVALUATION MODE: "1"
54 + Eval Mode: "1"
55 + EVAL MODE: "1"
56 + Evaluation Expired: "2"
57 + EVALUATION EXPIRED: "2"
58 + Eval Expired: "2"
59 + EVAL EXPIRED: "2"
60 + Authorization Expired: "2"
61 + AUTHORIZATION EXPIRED: "2"
62 + Overage: "1"
63 + OVERAGE: "1"
64 + Not In Use: "0"
65 + NOT IN USE: "0"
66 + signals:
67 + authorization:
68 + timestamp:
69 + OID: 1.3.6.1.4.1.9.9.831.0.7.1.0
70 + name: ciscoSlaAuthExpireTime
71 +
72 + - MIB: CISCO-SMART-LIC-MIB
73 + id: smart_id_certificate
74 + identity:
75 + id:
76 + value: smart_id_certificate
77 + name:
78 + value: Smart Licensing ID certificate
79 + component:
80 + value: smart_licensing
81 + descriptors:
82 + type:
83 + value: certificate
84 + signals:
85 + certificate:
86 + timestamp:
87 + OID: 1.3.6.1.4.1.9.9.831.0.6.3.0
88 + name: ciscoSlaNextCertificateExpireTime
89 +
90 + - MIB: CISCO-SMART-LIC-MIB
91 + id: smart_evaluation_period
92 + identity:
93 + id:
94 + value: smart_evaluation_period
95 + name:
96 + value: Smart Licensing evaluation period
97 + component:
98 + value: smart_licensing
99 + descriptors:
100 + type:
101 + value: evaluation
102 + signals:
103 + grace:
104 + timestamp:
105 + OID: 1.3.6.1.4.1.9.9.831.0.7.4.2.0
106 + name: ciscoSlaAuthEvalExpiredTime
107 +
108 + - MIB: CISCO-SMART-LIC-MIB
109 + id: smart_entitlements
110 + table:
111 + OID: 1.3.6.1.4.1.9.9.831.0.5.1
112 + name: ciscoSlaEntitlementInfoTable
113 + identity:
114 + id:
115 + OID: 1.3.6.1.4.1.9.9.831.0.5.1.1.3
116 + name: ciscoSlaEntitlementTag
117 + name:
118 + OID: 1.3.6.1.4.1.9.9.831.0.5.1.1.7
119 + name: ciscoSlaEntitlementFeatureName
120 + feature:
121 + OID: 1.3.6.1.4.1.9.9.831.0.5.1.1.4
122 + name: ciscoSlaEntitlementVersion
123 + component:
124 + value: smart_licensing
125 + descriptors:
126 + type:
127 + value: entitlement
128 + impact:
129 + OID: 1.3.6.1.4.1.9.9.831.0.5.1.1.6
130 + name: ciscoSlaEntitlementDescription
131 + state:
132 + OID: 1.3.6.1.4.1.9.9.831.0.5.1.1.5
133 + name: ciscoSlaEntitlementEnforceMode
134 + mapping:
135 + 1: "1"
136 + 2: "1"
137 + 3: "0"
138 + 4: "2"
139 + 5: "1"
140 + 6: "1"
141 + 7: "2"
142 + 8: "2"
143 + 9: "2"
144 + 10: "2"
145 + 11: "2"
146 + 12: "2"
147 + 13: "2"
148 + 14: "0"
149 + 15: "0"
150 + 16: "0"
151 + signals:
152 + usage:
153 + used:
154 + OID: 1.3.6.1.4.1.9.9.831.0.5.1.1.2
155 + name: ciscoSlaEntitlementRequestCount
src/go/plugin/go.d/config/go.d/snmp.profiles/default/_cisco-licensing-traditional.yaml new
+69
@@ -0,0 +1,69 @@
1 +# Cisco traditional licensing mixin.
2 +
3 +licensing:
4 + - MIB: CISCO-LICENSE-MGMT-MIB
5 + id: cisco_traditional_licenses
6 + table:
7 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1
8 + name: clmgmtLicenseInfoTable
9 + identity:
10 + id:
11 + index_transform:
12 + - start: 0
13 + end: 2
14 + name:
15 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.3
16 + name: clmgmtLicenseFeatureName
17 + feature:
18 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.4
19 + name: clmgmtLicenseFeatureVersion
20 + component:
21 + value: traditional_licensing
22 + descriptors:
23 + type:
24 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.5
25 + name: clmgmtLicenseType
26 + mapping:
27 + 1: demo
28 + 2: extension
29 + 3: grace_period
30 + 4: permanent
31 + 5: paid_subscription
32 + 6: evaluation_subscription
33 + 7: extension_subscription
34 + 8: eval_right_to_use
35 + 9: right_to_use
36 + 10: permanent_right_to_use
37 + perpetual:
38 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.5
39 + name: clmgmtLicenseType
40 + mapping:
41 + 4: "true"
42 + 9: "true"
43 + 10: "true"
44 + impact:
45 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.13
46 + name: clmgmtLicenseComments
47 + state:
48 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.14
49 + name: clmgmtLicenseStatus
50 + mapping:
51 + 1: "0"
52 + 2: "0"
53 + 3: "0"
54 + 4: "2"
55 + 5: "2"
56 + 6: "2"
57 + signals:
58 + expiry:
59 + timestamp:
60 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.16
61 + name: clmgmtLicenseEndDate
62 + format: snmp_dateandtime
63 + usage:
64 + capacity:
65 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.10
66 + name: clmgmtLicenseMaxUsageCount
67 + available:
68 + OID: 1.3.6.1.4.1.9.9.543.1.2.3.1.11
69 + name: clmgmtLicenseUsageCountRemaining
src/go/plugin/go.d/config/go.d/snmp.profiles/default/bluecoat-proxysg.yaml new
+92
@@ -0,0 +1,92 @@
1 +# Profile for Blue Coat ProxySG / SGOS appliances
2 +#
3 +# BLUECOAT-MIB defines the ProxySG product tree under 1.3.6.1.4.1.3417.1.1.*
4 +# and BLUECOAT-LICENSE-MIB exposes per-license application / feature / component
5 +# rows with expire type, expire date, and license state.
6 +
7 +extends:
8 + - _system-base.yaml
9 + - _std-if-mib.yaml
10 +
11 +selector:
12 + - sysobjectid:
13 + include:
14 + - 1.3.6.1.4.1.3417.1.1.*
15 +
16 +metadata:
17 + device:
18 + fields:
19 + vendor:
20 + value: "Blue Coat"
21 + serial_number:
22 + symbol:
23 + MIB: BLUECOAT-SG-PROXY-MIB
24 + OID: 1.3.6.1.4.1.3417.2.11.1.4.0
25 + name: sgProxySerialNumber
26 + version:
27 + symbol:
28 + MIB: BLUECOAT-SG-PROXY-MIB
29 + OID: 1.3.6.1.4.1.3417.2.11.1.3.0
30 + name: sgProxyVersion
31 + product_name:
32 + symbol:
33 + MIB: BLUECOAT-SG-PROXY-MIB
34 + OID: 1.3.6.1.4.1.3417.2.11.1.2.0
35 + name: sgProxySoftware
36 + os_name:
37 + value: "SGOS"
38 + os_version:
39 + symbol:
40 + MIB: BLUECOAT-SG-PROXY-MIB
41 + OID: 1.3.6.1.4.1.3417.2.11.1.3.0
42 + name: sgProxyVersion
43 + type:
44 + value: "Proxy"
45 +
46 +# References: LibreNMS SGOS OS detection and BLUECOAT-LICENSE-MIB.
47 +
48 +licensing:
49 + - MIB: BLUECOAT-LICENSE-MIB
50 + id: app_license_status
51 + table:
52 + OID: 1.3.6.1.4.1.3417.2.16.1.1.1
53 + name: appLicenseStatusTable
54 + identity:
55 + id:
56 + index: 1
57 + name:
58 + OID: 1.3.6.1.4.1.3417.2.16.1.1.1.1.2
59 + name: appLicenseStatusApplicationName
60 + feature:
61 + OID: 1.3.6.1.4.1.3417.2.16.1.1.1.1.3
62 + name: appLicenseStatusFeatureName
63 + component:
64 + OID: 1.3.6.1.4.1.3417.2.16.1.1.1.1.4
65 + name: appLicenseStatusComponentName
66 + descriptors:
67 + type:
68 + OID: 1.3.6.1.4.1.3417.2.16.1.1.1.1.5
69 + name: appLicenseStatusExpireType
70 + mapping:
71 + 0: unknown
72 + 1: perpetual
73 + 2: subscription
74 + 3: demo
75 + perpetual:
76 + OID: 1.3.6.1.4.1.3417.2.16.1.1.1.1.5
77 + name: appLicenseStatusExpireType
78 + mapping:
79 + 1: "true"
80 + state:
81 + OID: 1.3.6.1.4.1.3417.2.16.1.1.1.1.7
82 + name: appLicenseStatusLicenseState
83 + mapping:
84 + 0: "1"
85 + 1: "0"
86 + 2: "2"
87 + signals:
88 + expiry:
89 + timestamp:
90 + OID: 1.3.6.1.4.1.3417.2.16.1.1.1.1.6
91 + name: appLicenseStatusExpireDate
92 + format: snmp_dateandtime
src/go/plugin/go.d/config/go.d/snmp.profiles/default/checkpoint.yaml
+56
@@ -67,6 +67,62 @@ metadata:
67 type:
68 value: "Firewall"
69
70 +licensing:
71 + - id: licensing_blades
72 + MIB: CHECKPOINT-MIB
73 + table:
74 + OID: 1.3.6.1.4.1.2620.1.6.18.1
75 + name: licensingTable
76 + identity:
77 + id:
78 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.2
79 + name: licensingID
80 + name:
81 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.4
82 + name: licensingBladeName
83 + component:
84 + value: blade
85 + descriptors:
86 + type:
87 + value: subscription
88 + impact:
89 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.7
90 + name: licensingImpact
91 + state:
92 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.5
93 + name: licensingState
94 + mapping:
95 + # MIB-documented hyphenated tokens
96 + valid: "0"
97 + "up-to-date": "0"
98 + "about-to-expire": "1"
99 + unknown: "1"
100 + expired: "2"
101 + "not-associated": "2"
102 + # Real on-wire TitleCase variants observed on production gateways
103 + Valid: "0"
104 + "Up To Date": "0"
105 + "About To Expire": "1"
106 + Unknown: "1"
107 + Expired: "2"
108 + "Not Associated": "2"
109 + "Not Entitled": "2"
110 + Evaluation: "1"
111 + Trial: "1"
112 + signals:
113 + expiry:
114 + timestamp:
115 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.6
116 + name: licensingExpirationDate
117 + sentinel: [timer_u32_max]
118 + usage:
119 + used:
120 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.10
121 + name: licensingUsedQuota
122 + capacity:
123 + OID: 1.3.6.1.4.1.2620.1.6.18.1.1.9
124 + name: licensingTotalQuota
125 +
126 metrics:
127 # CPU
128 - MIB: CHECKPOINT-MIB
src/go/plugin/go.d/config/go.d/snmp.profiles/default/cisco.yaml
+2
@@ -3,6 +3,8 @@
3 extends:
4 - _system-base.yaml
5 - _cisco-base.yaml
6 + - _cisco-licensing-traditional.yaml
7 + - _cisco-licensing-smart.yaml
8
9 selector:
10 - sysobjectid:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/fortinet-fortigate.yaml
+79
@@ -69,10 +69,89 @@ metadata:
69 match_value: "$1"
70 # Examples: `5.6.4` from `v5.6.4,build1575,180425 (GA)`
71
72 +# References:
73 +# https://docs.fortinet.com/document/fortigate/8.0.0/fortigate-mib-information-overview/293724/fortigate-system-mibs
74 # https://mibbrowser.online/mibdb_search.php?mib=FORTINET-FORTIGATE-MIB
75 # https://github.com/DanielleHuisman/observium-community-edition/blob/main/mibs/fortinet/FORTINET-FORTIGATE-MIB
76
77 +licensing:
78 + - MIB: FORTINET-FORTIGATE-MIB
79 + id: fortigate_contracts
80 + table:
81 + OID: 1.3.6.1.4.1.12356.101.4.6.3.1.2
82 + name: fgLicContractTable
83 + identity:
84 + id:
85 + OID: 1.3.6.1.4.1.12356.101.4.6.3.1.2.1.1
86 + name: fgLicContractDesc
87 + name:
88 + OID: 1.3.6.1.4.1.12356.101.4.6.3.1.2.1.1
89 + name: fgLicContractDesc
90 + component:
91 + value: device
92 + descriptors:
93 + type:
94 + value: contract
95 + signals:
96 + expiry:
97 + timestamp:
98 + OID: 1.3.6.1.4.1.12356.101.4.6.3.1.2.1.2
99 + name: fgLicContractExpiry
100 + format: text_date
101 +
102 + - MIB: FORTINET-FORTIGATE-MIB
103 + id: fortiguard_services
104 + table:
105 + OID: 1.3.6.1.4.1.12356.101.4.6.3.2.2
106 + name: fgLicVersionTable
107 + identity:
108 + id:
109 + OID: 1.3.6.1.4.1.12356.101.4.6.3.2.2.1.1
110 + name: fgLicVersionDesc
111 + name:
112 + OID: 1.3.6.1.4.1.12356.101.4.6.3.2.2.1.1
113 + name: fgLicVersionDesc
114 + feature:
115 + OID: 1.3.6.1.4.1.12356.101.4.6.3.2.2.1.3
116 + name: fgLicVersionNumber
117 + component:
118 + value: fortiguard
119 + descriptors:
120 + type:
121 + value: service
122 + signals:
123 + expiry:
124 + timestamp:
125 + OID: 1.3.6.1.4.1.12356.101.4.6.3.2.2.1.2
126 + name: fgLicVersionExpiry
127 + format: text_date
128 +
129 + - MIB: FORTINET-FORTIGATE-MIB
130 + id: forticare_account_contracts
131 + table:
132 + OID: 1.3.6.1.4.1.12356.101.4.6.3.3.2
133 + name: fgLicAlContractTable
134 + identity:
135 + id:
136 + OID: 1.3.6.1.4.1.12356.101.4.6.3.3.2.1.1
137 + name: fgLicAlContractDesc
138 + name:
139 + OID: 1.3.6.1.4.1.12356.101.4.6.3.3.2.1.1
140 + name: fgLicAlContractDesc
141 + component:
142 + value: account
143 + descriptors:
144 + type:
145 + value: account_contract
146 + signals:
147 + expiry:
148 + timestamp:
149 + OID: 1.3.6.1.4.1.12356.101.4.6.3.3.2.1.2
150 + name: fgLicAlContractExpiry
151 + format: text_date
152 +
153 metrics:
154 +
155 ### VPN
156 - MIB: FORTINET-FORTIGATE-MIB
157 symbol:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/mikrotik-router.yaml
+23
@@ -84,6 +84,29 @@ metric_tags:
84 symbol: mtxrLicVersion
85 tag: license_version
86
87 +licensing:
88 + - id: routeros_upgrade
89 + MIB: MIKROTIK-MIB
90 + identity:
91 + id:
92 + value: routeros_upgrade
93 + name:
94 + value: RouterOS upgrade entitlement
95 + component:
96 + value: routeros
97 + descriptors:
98 + type:
99 + value: upgrade_entitlement
100 + impact:
101 + value: RouterOS software upgrade entitlement for this device
102 + signals:
103 + expiry:
104 + timestamp:
105 + OID: 1.3.6.1.4.1.14988.1.1.4.2.0
106 + name: mtxrLicUpgrUntil
107 + format: snmp_dateandtime
108 + sentinel: [timer_pre_1971]
109 +
110 metrics:
111 - MIB: MIKROTIK-MIB
112 symbol:
src/go/plugin/go.d/config/go.d/snmp.profiles/default/sophos-xgs-firewall.yaml
+290
@@ -31,6 +31,296 @@ metric_tags:
31 # https://mibbrowser.online/mibdb_search.php?mib=SFOS-FIREWALL-MIB
32 # https://github.com/DanielleHuisman/observium-community-edition/blob/main/mibs/sophos/SFOS-FIREWALL-MIB
33
34 +licensing:
35 + - id: base_firewall
36 + MIB: SFOS-FIREWALL-MIB
37 + identity:
38 + id:
39 + value: base_firewall
40 + name:
41 + value: Base Firewall
42 + component:
43 + value: base_firewall
44 + descriptors:
45 + type:
46 + value: subscription
47 + impact:
48 + value: Sophos Base Firewall subscription state and expiry on the device
49 + state:
50 + OID: 1.3.6.1.4.1.2604.5.1.5.1.1.0
51 + name: sfosBaseFWLicRegStatus
52 + policy: sophos
53 + mapping:
54 + "0": "0"
55 + "1": "1"
56 + "2": "0"
57 + "3": "0"
58 + "4": "2"
59 + "5": "2"
60 + signals:
61 + expiry:
62 + timestamp:
63 + from: 1.3.6.1.4.1.2604.5.1.5.1.2.0
64 + name: sfosBaseFWLicExpiryDate
65 + format: text_date
66 + sentinel: [timer_zero_or_negative]
67 + - id: network_protection
68 + MIB: SFOS-FIREWALL-MIB
69 + identity:
70 + id:
71 + value: network_protection
72 + name:
73 + value: Network Protection
74 + component:
75 + value: network_protection
76 + descriptors:
77 + type:
78 + value: subscription
79 + impact:
80 + value: Sophos Network Protection subscription state and expiry on the device
81 + state:
82 + OID: 1.3.6.1.4.1.2604.5.1.5.2.1.0
83 + name: sfosNetProtectionLicRegStatus
84 + policy: sophos
85 + mapping:
86 + "0": "0"
87 + "1": "1"
88 + "2": "0"
89 + "3": "0"
90 + "4": "2"
91 + "5": "2"
92 + signals:
93 + expiry:
94 + timestamp:
95 + from: 1.3.6.1.4.1.2604.5.1.5.2.2.0
96 + name: sfosNetProtectionLicExpiryDate
97 + format: text_date
98 + sentinel: [timer_zero_or_negative]
99 + - id: web_protection
100 + MIB: SFOS-FIREWALL-MIB
101 + identity:
102 + id:
103 + value: web_protection
104 + name:
105 + value: Web Protection
106 + component:
107 + value: web_protection
108 + descriptors:
109 + type:
110 + value: subscription
111 + impact:
112 + value: Sophos Web Protection subscription state and expiry on the device
113 + state:
114 + OID: 1.3.6.1.4.1.2604.5.1.5.3.1.0
115 + name: sfosWebProtectionLicRegStatus
116 + policy: sophos
117 + mapping:
118 + "0": "0"
119 + "1": "1"
120 + "2": "0"
121 + "3": "0"
122 + "4": "2"
123 + "5": "2"
124 + signals:
125 + expiry:
126 + timestamp:
127 + from: 1.3.6.1.4.1.2604.5.1.5.3.2.0
128 + name: sfosWebProtectionLicExpiryDate
129 + format: text_date
130 + sentinel: [timer_zero_or_negative]
131 + - id: mail_protection
132 + MIB: SFOS-FIREWALL-MIB
133 + identity:
134 + id:
135 + value: mail_protection
136 + name:
137 + value: Mail Protection
138 + component:
139 + value: mail_protection
140 + descriptors:
141 + type:
142 + value: subscription
143 + impact:
144 + value: Sophos Mail Protection subscription state and expiry on the device
145 + state:
146 + OID: 1.3.6.1.4.1.2604.5.1.5.4.1.0
147 + name: sfosMailProtectionLicRegStatus
148 + policy: sophos
149 + mapping:
150 + "0": "0"
151 + "1": "1"
152 + "2": "0"
153 + "3": "0"
154 + "4": "2"
155 + "5": "2"
156 + signals:
157 + expiry:
158 + timestamp:
159 + from: 1.3.6.1.4.1.2604.5.1.5.4.2.0
160 + name: sfosMailProtectionLicExpiryDate
161 + format: text_date
162 + sentinel: [timer_zero_or_negative]
163 + - id: web_server_protection
164 + MIB: SFOS-FIREWALL-MIB
165 + identity:
166 + id:
167 + value: web_server_protection
168 + name:
169 + value: Web Server Protection
170 + component:
171 + value: web_server_protection
172 + descriptors:
173 + type:
174 + value: subscription
175 + impact:
176 + value: Sophos Web Server Protection subscription state and expiry on the device
177 + state:
178 + OID: 1.3.6.1.4.1.2604.5.1.5.5.1.0
179 + name: sfosWebServerProtectionLicRegStatus
180 + policy: sophos
181 + mapping:
182 + "0": "0"
183 + "1": "1"
184 + "2": "0"
185 + "3": "0"
186 + "4": "2"
187 + "5": "2"
188 + signals:
189 + expiry:
190 + timestamp:
191 + from: 1.3.6.1.4.1.2604.5.1.5.5.2.0
192 + name: sfosWebServerProtectionLicExpiryDate
193 + format: text_date
194 + sentinel: [timer_zero_or_negative]
195 + - id: sandstorm
196 + MIB: SFOS-FIREWALL-MIB
197 + identity:
198 + id:
199 + value: sandstorm
200 + name:
201 + value: Sandstorm
202 + component:
203 + value: sandstorm
204 + descriptors:
205 + type:
206 + value: subscription
207 + impact:
208 + value: Sophos Sandstorm subscription state and expiry on the device
209 + state:
210 + OID: 1.3.6.1.4.1.2604.5.1.5.6.1.0
211 + name: sfosSandstromLicRegStatus
212 + policy: sophos
213 + mapping:
214 + "0": "0"
215 + "1": "1"
216 + "2": "0"
217 + "3": "0"
218 + "4": "2"
219 + "5": "2"
220 + signals:
221 + expiry:
222 + timestamp:
223 + from: 1.3.6.1.4.1.2604.5.1.5.6.2.0
224 + name: sfosSandstromLicExpiryDate
225 + format: text_date
226 + sentinel: [timer_zero_or_negative]
227 + - id: enhanced_support
228 + MIB: SFOS-FIREWALL-MIB
229 + identity:
230 + id:
231 + value: enhanced_support
232 + name:
233 + value: Enhanced Support
234 + component:
235 + value: enhanced_support
236 + descriptors:
237 + type:
238 + value: support
239 + impact:
240 + value: Sophos Enhanced Support state and expiry on the device
241 + state:
242 + OID: 1.3.6.1.4.1.2604.5.1.5.7.1.0
243 + name: sfosEnhancedSupportLicRegStatus
244 + policy: sophos
245 + mapping:
246 + "0": "0"
247 + "1": "1"
248 + "2": "0"
249 + "3": "0"
250 + "4": "2"
251 + "5": "2"
252 + signals:
253 + expiry:
254 + timestamp:
255 + from: 1.3.6.1.4.1.2604.5.1.5.7.2.0
256 + name: sfosEnhancedSupportLicExpiryDate
257 + format: text_date
258 + sentinel: [timer_zero_or_negative]
259 + - id: enhanced_plus_support
260 + MIB: SFOS-FIREWALL-MIB
261 + identity:
262 + id:
263 + value: enhanced_plus_support
264 + name:
265 + value: Enhanced Plus Support
266 + component:
267 + value: enhanced_plus_support
268 + descriptors:
269 + type:
270 + value: support
271 + impact:
272 + value: Sophos Enhanced Plus Support state and expiry on the device
273 + state:
274 + OID: 1.3.6.1.4.1.2604.5.1.5.8.1.0
275 + name: sfosEnhancedPlusLicRegStatus
276 + policy: sophos
277 + mapping:
278 + "0": "0"
279 + "1": "1"
280 + "2": "0"
281 + "3": "0"
282 + "4": "2"
283 + "5": "2"
284 + signals:
285 + expiry:
286 + timestamp:
287 + from: 1.3.6.1.4.1.2604.5.1.5.8.2.0
288 + name: sfosEnhancedPlusLicExpiryDate
289 + format: text_date
290 + sentinel: [timer_zero_or_negative]
291 + - id: central_orchestration
292 + MIB: SFOS-FIREWALL-MIB
293 + identity:
294 + id:
295 + value: central_orchestration
296 + name:
297 + value: Central Orchestration
298 + component:
299 + value: central_orchestration
300 + descriptors:
301 + type:
302 + value: subscription
303 + impact:
304 + value: Sophos Central Orchestration state and expiry on the device
305 + state:
306 + OID: 1.3.6.1.4.1.2604.5.1.5.9.1.0
307 + name: sfosCentralOrchestrationLicRegStatus
308 + policy: sophos
309 + mapping:
310 + "0": "0"
311 + "1": "1"
312 + "2": "0"
313 + "3": "0"
314 + "4": "2"
315 + "5": "2"
316 + signals:
317 + expiry:
318 + timestamp:
319 + from: 1.3.6.1.4.1.2604.5.1.5.9.2.0
320 + name: sfosCentralOrchestrationLicExpiryDate
321 + format: text_date
322 + sentinel: [timer_zero_or_negative]
323 +
324 metrics:
325 - MIB: SFOS-FIREWALL-MIB
326 symbol:
src/health/health.d/snmp.conf new
+98
@@ -0,0 +1,98 @@
1 +# Network device licensing alerts from the SNMP collector.
2 +
3 + template: snmp_license_expiring
4 + on: snmp.license.remaining_time
5 + class: Errors
6 + type: NetworkDevice
7 +component: Licensing
8 + calc: $remaining_time / 86400
9 + units: days
10 + every: 60s
11 + warn: $this < 30
12 + crit: $this < 7
13 + summary: SNMP device license expiring soon
14 + info: The earliest monitored SNMP license or subscription on this device is close to expiration.
15 + to: sysadmin
16 +
17 + template: snmp_license_authorization_expiring
18 + on: snmp.license.authorization_remaining_time
19 + class: Errors
20 + type: NetworkDevice
21 +component: Licensing
22 + calc: $remaining_time / 86400
23 + units: days
24 + every: 60s
25 + warn: $this < 30
26 + crit: $this < 7
27 + summary: SNMP device license authorization expiring soon
28 + info: The license authorization timer on this device is close to expiration.
29 + to: sysadmin
30 +
31 + template: snmp_license_certificate_expiring
32 + on: snmp.license.certificate_remaining_time
33 + class: Errors
34 + type: NetworkDevice
35 +component: Licensing
36 + calc: $remaining_time / 86400
37 + units: days
38 + every: 60s
39 + warn: $this < 30
40 + crit: $this < 7
41 + summary: SNMP device licensing certificate expiring soon
42 + info: The license certificate timer on this device is close to expiration.
43 + to: sysadmin
44 +
45 + template: snmp_license_grace_period_ending
46 + on: snmp.license.grace_remaining_time
47 + class: Errors
48 + type: NetworkDevice
49 +component: Licensing
50 + calc: $remaining_time / 86400
51 + units: days
52 + every: 60s
53 + warn: $this < 7
54 + crit: $this <= 0
55 + delay: down 5m multiplier 1.5 max 1h
56 + summary: SNMP device licensing grace period ending
57 + info: The licensing grace or evaluation period on this device is ending or already expired.
58 + to: sysadmin
59 +
60 + template: snmp_license_state_warning
61 + on: snmp.license.state
62 + class: Errors
63 + type: NetworkDevice
64 +component: Licensing
65 + calc: $degraded
66 + units: licenses
67 + every: 60s
68 + warn: $this > 0
69 + summary: SNMP device licensing degraded
70 + info: One or more monitored licenses on this device are degraded, in grace, or otherwise in warning state.
71 + to: sysadmin
72 +
73 + template: snmp_license_state_critical
74 + on: snmp.license.state
75 + class: Errors
76 + type: NetworkDevice
77 +component: Licensing
78 + calc: $broken
79 + units: licenses
80 + every: 60s
81 + crit: $this > 0
82 + summary: SNMP device licensing broken
83 + info: One or more monitored licenses on this device are expired, invalid, unauthorized, or otherwise in critical state.
84 + to: sysadmin
85 +
86 + template: snmp_license_usage_high
87 + on: snmp.license.usage_percent
88 + class: Utilization
89 + type: NetworkDevice
90 +component: Licensing
91 + calc: $usage_percent
92 + units: percentage
93 + every: 60s
94 + warn: $this >= 80
95 + crit: $this >= 95
96 + summary: SNMP device licensing usage is high
97 + info: The most constrained monitored license pool on this device is nearing exhaustion.
98 + to: sysadmin