diff --git a/.agents/skills/project-create-topology/SKILL.md b/.agents/skills/project-create-topology/SKILL.md index 1fc6d2338488ee..9a0bfadde24a34 100644 --- a/.agents/skills/project-create-topology/SKILL.md +++ b/.agents/skills/project-create-topology/SKILL.md @@ -100,7 +100,8 @@ developer-facing and must stay in this project skill, not under 4. Pick evidence rows. - Evidence is the lossless relationship proof. - For sockets, preserve the exact matching tuple. - - For SNMP/L2, preserve LLDP/CDP/FDB/ARP/STP facts according to role. + - For SNMP, preserve LLDP/CDP/FDB/ARP/STP facts according to role and keep + logical L3 adjacency in distinct L3 link/evidence types. - For streaming, keep relationship facts separate from actor-owned path data. - For vSphere, preserve inventory/relationship facts using stable object IDs. @@ -395,9 +396,9 @@ For `topology:streaming` actor modals: so Cloud aggregation can preserve multiple retaining parents and a future explicitly named `Retained by` section can be added without changing facts. -## SNMP/L2 Modal Rules +## SNMP Topology Modal Rules -For SNMP/L2 managed device actor modals: +For SNMP managed device actor modals: - Treat the device as a collection of ports. The primary section is `Ports` over `actor_ports`. @@ -418,6 +419,13 @@ For SNMP/L2 managed device actor modals: confidence, inference, attachment mode, or timestamps. - `actor_port_links` may carry compact side-specific refs and scalar facts, but must not duplicate raw LLDP/CDP/FDB/ARP/STP evidence JSON. +- Keep logical L3 adjacency out of `actor_port_links`. `l3_subnet` is not a + physical or L2 port-neighbor relationship. +- Expose logical L3 adjacency through typed `l3_subnet` relationship evidence + and an evidence-backed device modal section such as `L3 Adjacencies`. +- `l3_subnet` links represent shared-subnet logical L3 adjacency between + resolved managed SNMP device actors. They must use explicit L3 link/evidence + types and must not be presented as discovery, physical, or L2 links. - Keep generic graph-link `Links` sections only for endpoint, segment, or custom actors that do not own port inventory. - Build link endpoint port labels only from real port fields: `port_name`, diff --git a/.agents/sow/specs/topology-function-schema.md b/.agents/sow/specs/topology-function-schema.md index 76a8be76f82fc1..d8436bdd97d1af 100644 --- a/.agents/sow/specs/topology-function-schema.md +++ b/.agents/sow/specs/topology-function-schema.md @@ -16,7 +16,7 @@ The production schema is defined by: - `src/plugins.d/FUNCTION_TOPOLOGY_DEVELOPER_GUIDE.md` The schema is generic across topology domains. It applies to network -connections, streaming, SNMP/L2, vSphere, and future topology producers. +connections, streaming, SNMP, vSphere, and future topology producers. ## Planes @@ -587,15 +587,29 @@ deduplicated actor-label values when available. `topology:snmp` now emits `netdata.topology.v1` from the Function handler through an adapter over the existing SNMP topology engine output. The adapter -preserves actors, links, L2 observation evidence, actor metadata, and actor -custom detail tables. Remaining SNMP refinement is to promote interface metric -lookup fragments into first-class overlay templates/refs. +preserves actors, links, L2 observation evidence, bounded L3 adjacency +evidence, actor metadata, and actor custom detail tables. Remaining SNMP +refinement is to promote interface metric lookup fragments into first-class +overlay templates/refs. + +SNMP `l3_subnet` links are logical L3 adjacency, not physical or L2 adjacency. +They use `orientation: observed_bidirectional`, `direction_role: observation`, +and `semantic_role: normal`. The producer emits them only between resolved +managed SNMP device actors; unresolved or ambiguous L3 endpoints remain +diagnostic/suppression state and must not create IP-only graph actors. + +SNMP `l3_subnet` evidence must preserve the relationship facts needed for +drilldown and aggregation, including a `link_ref`, source/destination actors, +source/destination interface IPs, subnet, network, netmask, prefix, source, and +logical inference/attachment metadata. The evidence is an actor-modal source +for L3 adjacency drilldowns. SNMP modal composition must be port-centric for managed device actors. A managed device modal uses actor-label identification for important device facts, a primary `Ports` section over `actor_ports`, and a `Port Neighbors` section over -`actor_port_links`. Generic graph-link `Links` sections are reserved for -endpoint, segment, or custom actors that do not own port inventory. +`actor_port_links`. It may also expose an `L3 Adjacencies` section sourced from +`l3_subnet` relationship evidence. Generic graph-link `Links` sections are +reserved for endpoint, segment, or custom actors that do not own port inventory. SNMP `actor_ports` exposes real port identity and status as typed columns: SNMP `if_index` as the visible numeric port ID when known, source `port_id`, @@ -616,6 +630,10 @@ state, evidence count, confidence, inference, attachment mode, and timestamps. It exists so device modals can align neighbor rows with the same port identity shown in `actor_ports`; it is not a second copy of raw evidence. +SNMP `actor_port_links` must remain port-neighbor oriented. It must not include +`l3_subnet` links, because shared-subnet L3 adjacency does not prove a physical +or L2 port neighbor. + SNMP polished UI must not depend on raw `actor_metadata` and endpoint JSON. Important scalar/count summary values live in typed actor or actor-detail columns and are also available through `actor_labels`. Nested neighbors, VLANs, diff --git a/.agents/sow/specs/topology-modes-correlation-aggregation.md b/.agents/sow/specs/topology-modes-correlation-aggregation.md index a1eb3a03441b99..194ea0ac287488 100644 --- a/.agents/sow/specs/topology-modes-correlation-aggregation.md +++ b/.agents/sow/specs/topology-modes-correlation-aggregation.md @@ -29,7 +29,7 @@ rules, table merge policies, and presentation recipes in the payload. ## Terms - **Producer**: the Agent Function that emits one `netdata.topology.v1` - payload, for example network-connections, SNMP/L2, streaming, or vSphere. + payload, for example network-connections, SNMP, streaming, or vSphere. - **Aggregator**: Cloud service that fans out to many producers, decodes their payloads, correlates them, optionally aggregates detail, and returns a normal `netdata.topology.v1` payload. @@ -88,7 +88,7 @@ aggregator returns either detailed or aggregated output according to the original user request. If a producer does not expose `__topology_mode`, the aggregator must not invent -that parameter for it. SNMP/L2 and streaming are expected to be mode-invariant +that parameter for it. SNMP and streaming are expected to be mode-invariant unless a future producer change defines a real mode difference. ### Final Output @@ -497,20 +497,32 @@ Aggregator detailed: - Show exact socket evidence with resolved actors where possible. - Unresolved rows retain their loose-side facts. -## SNMP/L2 +## SNMP ### Domain Model -SNMP/L2 topology observations are device, interface, neighbor, forwarding, -ARP, bridge, VLAN, and protocol facts. A graph link represents an observed or -inferred L2 relationship between two actors. +SNMP topology observations are device, interface, neighbor, forwarding, ARP, +bridge, VLAN, protocol, and bounded L3 adjacency facts. -SNMP is not a loose-side topology. Every link should have two actors in both -Agent and aggregator views. +Most graph links represent observed or inferred L2 relationships between two +actors. SNMP may also emit logical L3 relationships with their own link and +evidence types. These links MUST NOT be presented as physical or L2 adjacency. + +`l3_subnet` represents an observed shared-subnet relationship between two +managed SNMP device actors. It is currently derived from interface IP/netmask +state collected from IP-MIB `ipAddrTable`, limited to IPv4 point-to-point +`/30` and `/31` subnets. It means both devices have managed interfaces in the +same point-to-point subnet; it does not prove that the devices are physically +connected. + +SNMP is not a loose-side topology. Every graph link should have two actors in +both Agent and aggregator views. `l3_subnet` follows the same rule: unmatched +or ambiguous L3 endpoints are diagnostic/suppression state, not materialized +IP-only graph actors. ### Mode Behavior -SNMP/L2 detailed and aggregated modes are currently a no-op. The producer should +SNMP detailed and aggregated modes are currently a no-op. The producer should not expose `__topology_mode` until it has a real lower/higher-grain distinction. If a global Cloud topology request asks for aggregated output, the aggregator @@ -546,9 +558,14 @@ switch-a port 10 -> managed-switch-b The weaker LLDP remote actor is removed from the aggregated output and its incident links/tables are rewired to the managed device actor. -### UI SNMP/L2 +For `l3_subnet`, aggregation MUST preserve the explicit `l3_subnet` link and +evidence type. Replacement may rewire endpoint actors to stronger managed +device actors, but it MUST NOT reinterpret `l3_subnet` as discovery protocol +evidence or port-neighbor evidence. + +### UI SNMP -The UI should not expose a detailed/aggregated toggle for SNMP/L2 unless the +The UI should not expose a detailed/aggregated toggle for SNMP unless the payload declares supported modes. Device modals should remain port-centric: @@ -562,6 +579,8 @@ Device modals should remain port-centric: when graph-link facts can align the port to a remote actor; - links/neighbor information: derived from the same port rows or aligned relationship rows so local port identity never contradicts the port table. +- L3 adjacency information: derived from typed `l3_subnet` relationship + evidence, not from `actor_port_links`. ## Streaming diff --git a/src/collectors/statsd.plugin/README.md b/src/collectors/statsd.plugin/README.md index 5eed2e576f8f70..3bbeae119607a0 100644 --- a/src/collectors/statsd.plugin/README.md +++ b/src/collectors/statsd.plugin/README.md @@ -692,6 +692,14 @@ To rename methods automatically: This adds dimensions named `GET`, `ADD`, and `DELETE`. +### Scope of StatsD Chart Configuration + +All chart and dimension configuration directives in `/etc/netdata/statsd.d/*.conf` control **local agent behavior only** — they define how the local Netdata agent processes, names, and visualizes statsd metrics it receives. + +The dimension `TYPE` field (see [Dimension Format](#dimension-format) above) selects which computed value of a metric a single agent displays. It does **not** control how that metric is combined across multiple Netdata instances. + +Cross-node aggregation — how metrics from multiple agents are combined in Netdata Cloud dashboards — is governed by the Cloud query engine, not by statsd configuration files. To choose Sum, Average, or another aggregation for a chart in Netdata Cloud, use the [aggregate function](../../../docs/dashboards-and-charts/netdata-charts.md#aggregate-functions-dropdown) on that chart. There is no `aggregation = SUM` or `aggregation = AVG` directive in statsd.d configuration. + ## Using StatsD with Different Languages
diff --git a/src/database/README.md b/src/database/README.md index edb8816e0485f4..6ead4d5c64bc2f 100644 --- a/src/database/README.md +++ b/src/database/README.md @@ -36,7 +36,14 @@ These limits are fully configurable. See [Changing how long Netdata stores metri ### Monitoring Retention Utilization -Netdata provides a visual representation of storage utilization for both the time and space limits across all Tiers under "Netdata" -> "dbengine retention" on the dashboard. This chart shows exactly how your storage space (disk space limits) and time (time limits) are used for metric retention. +Netdata provides a visual representation of storage utilization for both the time and space limits across all Tiers. In the dashboard, these are the **dbengine space and time retention** charts, found under the **Netdata** section → **dbengine retention** family — there is one chart per database tier. Each chart shows exactly how your storage space (disk space limits) and time (time limits) are used for metric retention. + +Each tier has its own independent **space** and **time** lines on the chart: + +- **Space (disk utilization %)**: the percentage of the tier's allocated disk space that is currently occupied, calculated as `disk_used / disk_max × 100`. When a size limit is configured for the tier, `disk_max` is that limit. When no size limit is configured (set to 0), `disk_max` is the total available disk space at the tier's data directory (free space plus already-used space). For example, a space value of 89% means 89% of the tier's allocated or available disk space is occupied by datafiles. +- **Time (retention time utilization %)**: the percentage of the configured retention time period that has been filled with data, calculated as `current_retention_duration / configured_time_limit × 100`. When no time limit is configured (set to 0), this dimension shows 0% and no time-based retention is enforced. For example, a time value of 61% means 61% of the configured time retention period has elapsed since the oldest sample. This value is capped at 100%. + +Different tiers naturally show different percentages because they have different write volumes, compression ratios, and configured limits. When either the space or time dimension reaches 100%, the oldest datafiles are rotated out as described in the [Retention Size Enforcement](#retention-size-enforcement) section below. ### Retention Size Enforcement diff --git a/src/go/plugin/go.d/collector/snmp_topology/func_topology_test.go b/src/go/plugin/go.d/collector/snmp_topology/func_topology_test.go index 998b60a069bd63..17f2e630548e44 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/func_topology_test.go +++ b/src/go/plugin/go.d/collector/snmp_topology/func_topology_test.go @@ -341,7 +341,7 @@ func TestSNMPTopologyToV1_PreservesActorCustomTables(t *testing.T) { require.NotNil(t, deviceType.Presentation.Modal.Labels) require.NotNil(t, deviceType.Presentation.Modal.Labels.Identification) assert.Equal(t, "management_ip", deviceType.Presentation.Modal.Labels.Identification.Fields[1].Key) - require.Len(t, deviceType.Presentation.Modal.Sections, 2) + require.Len(t, deviceType.Presentation.Modal.Sections, 3) assert.Equal(t, "ports", deviceType.Presentation.Modal.Sections[0].ID) assert.Equal(t, "if_index", deviceType.Presentation.Modal.Sections[0].Columns[0].ID) assert.Equal(t, "Port ID", deviceType.Presentation.Modal.Sections[0].Columns[0].Label) @@ -351,6 +351,9 @@ func TestSNMPTopologyToV1_PreservesActorCustomTables(t *testing.T) { assert.Equal(t, "port_neighbors", deviceType.Presentation.Modal.Sections[1].ID) assert.Equal(t, "actor_table", deviceType.Presentation.Modal.Sections[1].Source.Kind) assert.Equal(t, "actor_port_links", deviceType.Presentation.Modal.Sections[1].Source.Table) + assert.Equal(t, "l3_adjacencies", deviceType.Presentation.Modal.Sections[2].ID) + assert.Equal(t, "evidence", deviceType.Presentation.Modal.Sections[2].Source.Kind) + assert.Equal(t, snmpTopologyV1LinkL3Subnet, deviceType.Presentation.Modal.Sections[2].Source.Evidence) endpointType := payload.Types.ActorTypes["endpoint"] require.NotNil(t, endpointType.Presentation) @@ -470,6 +473,153 @@ func TestSNMPTopologyToV1_PortNamesOnlyUsePortFields(t *testing.T) { assert.Equal(t, []any{nil, nil}, topologyV1ColumnValues(t, portLinksTable, "remote_port_name")) } +func TestSNMPTopologyToV1_PreservesL3SubnetPresentationAndEvidence(t *testing.T) { + data := topologyData{ + AgentID: "agent-test", + View: "summary", + Actors: []topologyActor{ + { + ActorID: "router-a", + ActorType: "router", + Match: topologyMatch{ + IPAddresses: []string{"192.0.2.1"}, + SysName: "router-a", + }, + }, + { + ActorID: "router-b", + ActorType: "router", + Match: topologyMatch{ + IPAddresses: []string{"192.0.2.2"}, + SysName: "router-b", + }, + }, + }, + Links: []topologyLink{ + { + Protocol: topologyL3SubnetLinkType, + LinkType: topologyL3SubnetLinkType, + Direction: "observed", + SrcActorID: "router-a", + DstActorID: "router-b", + Src: topologyLinkEndpoint{ + Attributes: map[string]any{ + "if_index": uint64(10), + "if_name": "xe-0/0/0", + "ip": "192.0.2.1", + "subnet": "192.0.2.0/30", + }, + }, + Dst: topologyLinkEndpoint{ + Attributes: map[string]any{ + "if_index": uint64(20), + "if_name": "xe-0/0/1", + "ip": "192.0.2.2", + "subnet": "192.0.2.0/30", + }, + }, + Metrics: map[string]any{ + "source": "ip_mib", + "inference": "shared_subnet", + "attachment_mode": "logical_l3_subnet", + "subnet": "192.0.2.0/30", + "network": "192.0.2.0", + "netmask": "255.255.255.252", + "prefix": uint64(30), + }, + }, + }, + } + + payload, err := snmpTopologyToV1(data) + require.NoError(t, err) + require.NoError(t, validateTopologyV1Data(payload)) + + assert.Contains(t, payload.Producer.Capabilities, "l3_subnet") + require.NotNil(t, payload.Presentation) + assert.Equal(t, "snmp-l2.v2", payload.Presentation.ProfileVersion) + assert.Contains(t, topologyV1LegendLinkTypes(payload), snmpTopologyV1LinkL3Subnet) + + require.Contains(t, payload.Types.LinkTypes, snmpTopologyV1LinkL3Subnet) + linkType := payload.Types.LinkTypes[snmpTopologyV1LinkL3Subnet] + assert.Equal(t, "observed_bidirectional", linkType.Orientation) + assert.Equal(t, "observation", linkType.DirectionRole) + assert.Equal(t, "normal", linkType.SemanticRole) + require.NotNil(t, linkType.Presentation) + assert.Equal(t, "L3 subnet", linkType.Presentation.Label) + assert.Equal(t, "info", linkType.Presentation.ColorSlot) + assert.Equal(t, "dashed", linkType.Presentation.LineStyle) + assert.Equal(t, "normal", linkType.Presentation.Width) + + require.Contains(t, payload.Types.EvidenceTypes, snmpTopologyV1LinkL3Subnet) + evidenceType := payload.Types.EvidenceTypes[snmpTopologyV1LinkL3Subnet] + assert.Equal(t, snmpTopologyV1LinkL3Subnet, evidenceType.LinkType) + assert.Equal(t, []string{"src_actor", "dst_actor", "subnet", "src_ip", "dst_ip"}, evidenceType.MatchColumns) + + require.Contains(t, payload.Evidence, snmpTopologyV1LinkL3Subnet) + evidenceTable := payload.Evidence[snmpTopologyV1LinkL3Subnet].Table + assert.Equal(t, 1, evidenceTable.Rows) + assert.Equal(t, "link_ref", topologyV1ColumnType(evidenceTable, "link")) + assert.Equal(t, "string_ref", topologyV1ColumnType(evidenceTable, "src_ip")) + assert.Equal(t, "string_ref", topologyV1ColumnType(evidenceTable, "dst_ip")) + assert.Equal(t, "string_ref", topologyV1ColumnType(evidenceTable, "subnet")) + assert.Equal(t, "uint", topologyV1ColumnType(evidenceTable, "prefix")) + assert.Equal(t, []any{0}, topologyV1ColumnValues(t, evidenceTable, "link")) + assert.Equal(t, []string{"192.0.2.1"}, topologyV1StringColumnValues(t, payload, evidenceTable, "src_ip")) + assert.Equal(t, []string{"192.0.2.2"}, topologyV1StringColumnValues(t, payload, evidenceTable, "dst_ip")) + assert.Equal(t, []string{"192.0.2.0/30"}, topologyV1StringColumnValues(t, payload, evidenceTable, "subnet")) + assert.Equal(t, []any{uint64(30)}, topologyV1ColumnValues(t, evidenceTable, "prefix")) + assert.Equal(t, []string{"ip_mib"}, topologyV1StringColumnValues(t, payload, evidenceTable, "source")) + + assert.Equal(t, []string{snmpTopologyV1LinkL3Subnet}, topologyV1StringColumnValues(t, payload, payload.Links, "type")) + assert.Equal(t, []string{snmpTopologyV1LinkL3Subnet}, topologyV1StringColumnValues(t, payload, payload.Links, "protocol")) + + deviceType := payload.Types.ActorTypes["router"] + require.NotNil(t, deviceType.Presentation) + require.NotNil(t, deviceType.Presentation.Modal) + l3Section := requireTopologyV1ModalSection(t, deviceType.Presentation.Modal.Sections, "l3_adjacencies") + assert.Equal(t, "evidence", l3Section.Source.Kind) + assert.Equal(t, snmpTopologyV1LinkL3Subnet, l3Section.Source.Evidence) + require.NotNil(t, l3Section.OwnerFilter) + assert.Equal(t, "incident_evidence", l3Section.OwnerFilter.Mode) + assert.Equal(t, "link", l3Section.OwnerFilter.LinkColumn) + assert.Equal(t, "src_actor", l3Section.OwnerFilter.SrcActorColumn) + assert.Equal(t, "dst_actor", l3Section.OwnerFilter.DstActorColumn) + assert.Equal(t, "subnet", l3Section.Sort.Column) + assert.Equal(t, "selected_side_endpoint", l3Section.Columns[1].Projection.Kind) + assert.Equal(t, "endpoint", l3Section.Columns[1].Cell) + + if payload.Tables != nil { + assert.NotContains(t, payload.Tables.Actor, "actor_port_links") + } +} + +func TestSNMPTopologyToV1_ReturnsErrorForL3SubnetWithoutSubnet(t *testing.T) { + data := topologyData{ + AgentID: "agent-test", + Actors: []topologyActor{ + {ActorID: "router-a", ActorType: "router"}, + {ActorID: "router-b", ActorType: "router"}, + }, + Links: []topologyLink{ + { + Protocol: topologyL3SubnetLinkType, + LinkType: topologyL3SubnetLinkType, + SrcActorID: "router-a", + DstActorID: "router-b", + Metrics: map[string]any{ + "prefix": uint64(30), + }, + }, + }, + } + + _, err := snmpTopologyToV1(data) + + require.Error(t, err) + require.Contains(t, err.Error(), "l3_subnet link 0 is missing subnet") +} + func TestSNMPTopologyToV1_PreservesLinkPresentationTypes(t *testing.T) { ts := time.Date(2026, 5, 10, 12, 0, 0, 0, time.UTC) data := topologyData{ @@ -786,3 +936,15 @@ func topologyV1LegendLinkTypes(data topologyv1.Data) []string { } return out } + +func requireTopologyV1ModalSection(t *testing.T, sections []topologyv1.ModalSection, id string) topologyv1.ModalSection { + t.Helper() + + for _, section := range sections { + if section.ID == id { + return section + } + } + require.Failf(t, "missing modal section", "section %q not found", id) + return topologyv1.ModalSection{} +} diff --git a/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1.go b/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1.go index 849526400e37d3..208ecf4a667490 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1.go +++ b/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1.go @@ -24,6 +24,7 @@ const ( snmpTopologyV1LinkARP = "arp" snmpTopologyV1LinkSNMP = "snmp" snmpTopologyV1LinkProbable = "probable" + snmpTopologyV1LinkL3Subnet = topologyL3SubnetLinkType ) func snmpTopologyToV1(data topologyData) (topologyv1.Data, error) { @@ -105,6 +106,7 @@ func snmpTopologyToV1(data topologyData) (topologyv1.Data, error) { "cdp", "fdb", "stp", + "l3_subnet", }, }, CollectedAt: data.CollectedAt, diff --git a/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_actor_ports.go b/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_actor_ports.go index 57c6af564119d4..3183b5d4636f90 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_actor_ports.go +++ b/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_actor_ports.go @@ -68,6 +68,9 @@ func buildSNMPTopologyV1PortNeighborSummaries( } for _, link := range links { + if snmpTopologyV1LinkIsL3Subnet(link) { + continue + } appendSide(link.SrcActorID, link.DstActorID, link.Src, link.Dst) appendSide(link.DstActorID, link.SrcActorID, link.Dst, link.Src) } @@ -291,6 +294,9 @@ func buildSNMPTopologyV1ActorPortLinksTable( } for i, link := range links { + if snmpTopologyV1LinkIsL3Subnet(link) { + continue + } if err := appendSide(i, link, link.SrcActorID, link.DstActorID, link.Src, link.Dst); err != nil { return topologyv1.Table{}, err } diff --git a/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_links.go b/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_links.go index cfd258849840f5..adffc16a65723d 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_links.go +++ b/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_links.go @@ -48,6 +48,10 @@ func buildSNMPTopologyV1Links( evidenceCounts[i] = 1 discoveredAt[i] = nullableTime(link.DiscoveredAt) lastSeen[i] = nullableTime(link.LastSeen) + subnet := topologyMetricValueString(link.Metrics, "subnet") + if linkType == snmpTopologyV1LinkL3Subnet && subnet == "" { + return topologyv1.Table{}, nil, fmt.Errorf("l3_subnet link %d is missing subnet", i) + } srcEndpoint := nullableJSON(link.Src.Attributes) dstEndpoint := nullableJSON(link.Dst.Attributes) metrics := nullableJSON(link.Metrics) @@ -72,6 +76,13 @@ func buildSNMPTopologyV1Links( evidenceRows.confidences = append(evidenceRows.confidences, nullableStringRef(stringsDict, topologyMetricValueString(link.Metrics, "confidence"))) evidenceRows.inferences = append(evidenceRows.inferences, nullableStringRef(stringsDict, topologyMetricValueString(link.Metrics, "inference"))) evidenceRows.attachmentModes = append(evidenceRows.attachmentModes, nullableStringRef(stringsDict, topologyMetricValueString(link.Metrics, "attachment_mode"))) + evidenceRows.srcIPs = append(evidenceRows.srcIPs, nullableStringRef(stringsDict, topologyV1EndpointString(link.Src, "ip"))) + evidenceRows.dstIPs = append(evidenceRows.dstIPs, nullableStringRef(stringsDict, topologyV1EndpointString(link.Dst, "ip"))) + evidenceRows.subnets = append(evidenceRows.subnets, nullableStringRef(stringsDict, subnet)) + evidenceRows.networks = append(evidenceRows.networks, nullableStringRef(stringsDict, topologyMetricValueString(link.Metrics, "network"))) + evidenceRows.netmasks = append(evidenceRows.netmasks, nullableStringRef(stringsDict, topologyMetricValueString(link.Metrics, "netmask"))) + evidenceRows.prefixes = append(evidenceRows.prefixes, nullableUintValue(link.Metrics["prefix"])) + evidenceRows.sources = append(evidenceRows.sources, nullableStringRef(stringsDict, topologyMetricValueString(link.Metrics, "source"))) evidenceRows.srcEndpoints = append(evidenceRows.srcEndpoints, srcEndpoint) evidenceRows.dstEndpoints = append(evidenceRows.dstEndpoints, dstEndpoint) evidenceRows.metrics = append(evidenceRows.metrics, metrics) @@ -110,7 +121,7 @@ func buildSNMPTopologyV1Links( for linkType, rows := range evidenceRowsByType { evidenceSections[linkType] = topologyv1.EvidenceSection{ Type: linkType, - Table: rows.table(), + Table: rows.table(linkType), } } @@ -133,35 +144,82 @@ type snmpTopologyV1EvidenceRows struct { confidences []any inferences []any attachmentModes []any + srcIPs []any + dstIPs []any + subnets []any + networks []any + netmasks []any + prefixes []any + sources []any srcEndpoints []any dstEndpoints []any metrics []any } -func (rows *snmpTopologyV1EvidenceRows) table() topologyv1.Table { +func (rows *snmpTopologyV1EvidenceRows) table(linkType string) topologyv1.Table { return topologyv1.MustTable(len(rows.linkRefs), - snmpTopologyV1EvidenceColumns(), - []topologyv1.ColumnEncoding{ - topologyv1.Values(rows.linkRefs...), - topologyv1.Values(rows.srcActors...), - topologyv1.Values(rows.dstActors...), - topologyv1.Values(rows.protocols...), - topologyv1.Values(rows.directions...), - topologyv1.Values(rows.states...), - topologyv1.Values(rows.srcPortNames...), - topologyv1.Values(rows.dstPortNames...), - topologyv1.Values(rows.srcIfIndexes...), - topologyv1.Values(rows.dstIfIndexes...), - topologyv1.Values(rows.srcManagementIPs...), - topologyv1.Values(rows.dstManagementIPs...), - topologyv1.Values(rows.confidences...), - topologyv1.Values(rows.inferences...), - topologyv1.Values(rows.attachmentModes...), - topologyv1.Values(rows.srcEndpoints...), - topologyv1.Values(rows.dstEndpoints...), - topologyv1.Values(rows.metrics...), - }, + snmpTopologyV1EvidenceColumnsForType(linkType), + rows.columnEncodingsForType(linkType), + ) +} + +func (rows *snmpTopologyV1EvidenceRows) columnEncodingsForType(linkType string) []topologyv1.ColumnEncoding { + encodings := []topologyv1.ColumnEncoding{ + topologyv1.Values(rows.linkRefs...), + topologyv1.Values(rows.srcActors...), + topologyv1.Values(rows.dstActors...), + topologyv1.Values(rows.protocols...), + topologyv1.Values(rows.directions...), + topologyv1.Values(rows.states...), + topologyv1.Values(rows.srcPortNames...), + topologyv1.Values(rows.dstPortNames...), + topologyv1.Values(rows.srcIfIndexes...), + topologyv1.Values(rows.dstIfIndexes...), + topologyv1.Values(rows.srcManagementIPs...), + topologyv1.Values(rows.dstManagementIPs...), + topologyv1.Values(rows.confidences...), + topologyv1.Values(rows.inferences...), + topologyv1.Values(rows.attachmentModes...), + } + if linkType == snmpTopologyV1LinkL3Subnet { + encodings = append(encodings, + topologyv1.Values(rows.srcIPs...), + topologyv1.Values(rows.dstIPs...), + topologyv1.Values(rows.subnets...), + topologyv1.Values(rows.networks...), + topologyv1.Values(rows.netmasks...), + topologyv1.Values(rows.prefixes...), + topologyv1.Values(rows.sources...), + ) + } + encodings = append(encodings, + topologyv1.Values(rows.srcEndpoints...), + topologyv1.Values(rows.dstEndpoints...), + topologyv1.Values(rows.metrics...), ) + return encodings +} + +func snmpTopologyV1EvidenceColumnsForType(linkType string) []topologyv1.Column { + columns := snmpTopologyV1EvidenceColumns() + if linkType != snmpTopologyV1LinkL3Subnet { + return columns + } + extras := []topologyv1.Column{ + topologyv1.NewColumn("src_ip", "string_ref", topologyv1.WithDictionary("strings"), topologyv1.WithNullable()), + topologyv1.NewColumn("dst_ip", "string_ref", topologyv1.WithDictionary("strings"), topologyv1.WithNullable()), + topologyv1.NewColumn("subnet", "string_ref", topologyv1.WithDictionary("strings"), topologyv1.WithRole("group_key")), + topologyv1.NewColumn("network", "string_ref", topologyv1.WithDictionary("strings"), topologyv1.WithNullable()), + topologyv1.NewColumn("netmask", "string_ref", topologyv1.WithDictionary("strings"), topologyv1.WithNullable()), + topologyv1.NewColumn("prefix", "uint", topologyv1.WithNullable()), + topologyv1.NewColumn("source", "string_ref", topologyv1.WithDictionary("strings"), topologyv1.WithNullable()), + } + insertAt := len(columns) - 3 + out := make([]topologyv1.Column, 0, len(columns)+len(extras)) + out = append(out, columns[:insertAt]...) + out = append(out, extras...) + out = append(out, columns[insertAt:]...) + return out } func snmpTopologyV1EvidenceColumns() []topologyv1.Column { @@ -207,11 +265,17 @@ func snmpTopologyV1LinkType(link topologyLink) string { return snmpTopologyV1LinkARP case snmpTopologyV1LinkSNMP: return snmpTopologyV1LinkSNMP + case snmpTopologyV1LinkL3Subnet: + return snmpTopologyV1LinkL3Subnet default: return snmpTopologyV1LinkObservation } } +func snmpTopologyV1LinkIsL3Subnet(link topologyLink) bool { + return snmpTopologyV1LinkType(link) == snmpTopologyV1LinkL3Subnet +} + func snmpTopologyV1LinkIsProbable(link topologyLink) bool { if strings.EqualFold(strings.TrimSpace(link.State), snmpTopologyV1LinkProbable) { return true diff --git a/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_presentation.go b/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_presentation.go index 707a905d78e14f..83222f285391d1 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_presentation.go +++ b/src/go/plugin/go.d/collector/snmp_topology/func_topology_v1_presentation.go @@ -153,6 +153,7 @@ func snmpTopologyV1DeviceModal() *topologyv1.ModalPresentation { Sort: &topologyv1.ModalSort{Column: "if_index", Direction: "asc"}, }, snmpTopologyV1PortLinksSection(2), + snmpTopologyV1L3SubnetSection(3), }, } } @@ -249,6 +250,47 @@ func snmpTopologyV1PortLinksSection(order int) topologyv1.ModalSection { } } +func snmpTopologyV1L3SubnetSection(order int) topologyv1.ModalSection { + return topologyv1.ModalSection{ + ID: "l3_adjacencies", + Label: "L3 Adjacencies", + Order: order, + Source: topologyv1.ModalSource{ + Kind: "evidence", + Evidence: snmpTopologyV1LinkL3Subnet, + }, + OwnerFilter: &topologyv1.ModalOwnerFilter{ + Mode: "incident_evidence", + LinkColumn: "link", + SrcActorColumn: "src_actor", + DstActorColumn: "dst_actor", + }, + Columns: []topologyv1.ModalColumn{ + { + ID: "remote", + Label: "Remote Actor", + Projection: topologyv1.ModalProjection{ + Kind: "opposite_actor", + SrcActorColumn: "src_actor", + DstActorColumn: "dst_actor", + }, + Cell: "actor_link", + }, + modalSelectedSideEndpointColumn("local_endpoint", "Local Endpoint", "src_ip", "src_port_name", "dst_ip", "dst_port_name"), + modalSelectedSideEndpointColumn("remote_endpoint", "Remote Endpoint", "dst_ip", "dst_port_name", "src_ip", "src_port_name"), + modalDirectColumn("subnet", "Subnet", "subnet", "text"), + modalDirectColumn("prefix", "Prefix", "prefix", "number"), + modalDirectColumnWithVisibility("network", "Network", "network", "text", "expanded"), + modalDirectColumnWithVisibility("netmask", "Netmask", "netmask", "text", "expanded"), + modalDirectColumnWithVisibility("source", "Source", "source", "badge", "expanded"), + modalDirectColumnWithVisibility("inference", "Inference", "inference", "badge", "expanded"), + modalDirectColumnWithVisibility("attachment_mode", "Attachment", "attachment_mode", "badge", "expanded"), + }, + Sort: &topologyv1.ModalSort{Column: "subnet", Direction: "asc"}, + EmptyLabel: "No L3 adjacencies", + } +} + func modalSelectedSidePortColumn(id, label, selectedSrcPortColumn, selectedDstPortColumn string) topologyv1.ModalColumn { return topologyv1.ModalColumn{ ID: id, @@ -264,6 +306,23 @@ func modalSelectedSidePortColumn(id, label, selectedSrcPortColumn, selectedDstPo } } +func modalSelectedSideEndpointColumn(id, label, selectedSrcIPColumn, selectedSrcPortColumn, selectedDstIPColumn, selectedDstPortColumn string) topologyv1.ModalColumn { + return topologyv1.ModalColumn{ + ID: id, + Label: label, + Projection: topologyv1.ModalProjection{ + Kind: "selected_side_endpoint", + SrcActorColumn: "src_actor", + DstActorColumn: "dst_actor", + LocalIPColumn: selectedSrcIPColumn, + LocalPortColumn: selectedSrcPortColumn, + RemoteIPColumn: selectedDstIPColumn, + RemotePortColumn: selectedDstPortColumn, + }, + Cell: "endpoint", + } +} + func modalDirectColumn(id, label, sourceColumn, cell string) topologyv1.ModalColumn { return topologyv1.ModalColumn{ ID: id, @@ -328,6 +387,7 @@ func snmpTopologyV1LinkTypeSpecs() []snmpTopologyV1LinkTypeSpec { {id: snmpTopologyV1LinkFDB, label: "FDB", colorSlot: "neutral", lineStyle: "solid", width: "normal", semanticRole: "normal"}, {id: snmpTopologyV1LinkSTP, label: "STP", colorSlot: "muted", lineStyle: "solid", width: "normal", semanticRole: "normal"}, {id: snmpTopologyV1LinkARP, label: "ARP", colorSlot: "muted", lineStyle: "solid", width: "normal", semanticRole: "normal"}, + {id: snmpTopologyV1LinkL3Subnet, label: "L3 subnet", colorSlot: "info", lineStyle: "dashed", width: "normal", semanticRole: "normal"}, {id: snmpTopologyV1LinkSNMP, label: "SNMP", colorSlot: "primary", lineStyle: "solid", width: "normal", semanticRole: "normal"}, {id: snmpTopologyV1LinkProbable, label: "Probable", colorSlot: "dim", lineStyle: "solid", width: "normal", semanticRole: "normal"}, {id: snmpTopologyV1LinkObservation, label: "L2 observation", colorSlot: "neutral", lineStyle: "solid", width: "normal", semanticRole: "normal"}, @@ -368,22 +428,37 @@ func snmpTopologyV1EvidenceTypes() map[string]topologyv1.EvidenceType { types[spec.id] = topologyv1.EvidenceType{ LinkType: spec.id, Role: "observation_evidence", - Columns: snmpTopologyV1EvidenceColumns(), - MatchColumns: []string{ - "src_actor", - "dst_actor", - "protocol", - "src_endpoint", - "dst_endpoint", - }, + Columns: snmpTopologyV1EvidenceColumnsForType(spec.id), + MatchColumns: snmpTopologyV1EvidenceMatchColumnsForType( + spec.id, + ), } } return types } +func snmpTopologyV1EvidenceMatchColumnsForType(linkType string) []string { + if linkType == snmpTopologyV1LinkL3Subnet { + return []string{ + "src_actor", + "dst_actor", + "subnet", + "src_ip", + "dst_ip", + } + } + return []string{ + "src_actor", + "dst_actor", + "protocol", + "src_endpoint", + "dst_endpoint", + } +} + func snmpTopologyV1Presentation() *topologyv1.Presentation { return &topologyv1.Presentation{ - ProfileVersion: "snmp-l2.v1", + ProfileVersion: "snmp-l2.v2", Selection: &topologyv1.SelectionPresentation{ ActorClick: &topologyv1.ActorClickPresentation{Mode: "highlight_connections"}, }, @@ -410,6 +485,7 @@ func snmpTopologyV1Presentation() *topologyv1.Presentation { {Type: snmpTopologyV1LinkCDP, Label: "CDP"}, {Type: snmpTopologyV1LinkSNMP, Label: "SNMP"}, {Type: snmpTopologyV1LinkBridge, Label: "Bridge"}, + {Type: snmpTopologyV1LinkL3Subnet, Label: "L3 subnet"}, {Type: snmpTopologyV1LinkProbable, Label: "Probable"}, }, Ports: []topologyv1.LegendEntry{ diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_cache.go b/src/go/plugin/go.d/collector/snmp_topology/topology_cache.go index d95d161400875a..ad99d22ae23a2b 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_cache.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_cache.go @@ -24,6 +24,7 @@ type topologyCache struct { ifStatusByIndex map[string]ifStatus ifIndexByIP map[string]string ifNetmaskByIP map[string]string + l3InterfacesByIP map[string]topologyL3Interface bridgePortToIf map[string]string fdbEntries map[string]*fdbEntry fdbIDToVlanID map[string]string diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_cache_interfaces.go b/src/go/plugin/go.d/collector/snmp_topology/topology_cache_interfaces.go index b85b5e322572bd..a1dd07f6a4f722 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_cache_interfaces.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_cache_interfaces.go @@ -94,6 +94,11 @@ func (c *topologyCache) updateIfIndexByIP(tags map[string]string) { }) if mask := normalizeIPAddress(tags[tagTopoIPMask]); mask != "" { c.ifNetmaskByIP[ip] = mask + c.l3InterfacesByIP[ip] = topologyL3Interface{ + IP: ip, + Netmask: mask, + IfIndex: ifIndex, + } } } diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_cache_lifecycle.go b/src/go/plugin/go.d/collector/snmp_topology/topology_cache_lifecycle.go index 9609d694593128..8d9f27a2379e6c 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_cache_lifecycle.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_cache_lifecycle.go @@ -9,19 +9,20 @@ import ( func newTopologyCache() *topologyCache { return &topologyCache{ - lldpLocPorts: make(map[string]*lldpLocPort), - lldpRemotes: make(map[string]*lldpRemote), - cdpRemotes: make(map[string]*cdpRemote), - ifNamesByIndex: make(map[string]string), - ifStatusByIndex: make(map[string]ifStatus), - ifIndexByIP: make(map[string]string), - ifNetmaskByIP: make(map[string]string), - bridgePortToIf: make(map[string]string), - fdbEntries: make(map[string]*fdbEntry), - fdbIDToVlanID: make(map[string]string), - vlanIDToName: make(map[string]string), - stpPorts: make(map[string]*stpPortEntry), - arpEntries: make(map[string]*arpEntry), + lldpLocPorts: make(map[string]*lldpLocPort), + lldpRemotes: make(map[string]*lldpRemote), + cdpRemotes: make(map[string]*cdpRemote), + ifNamesByIndex: make(map[string]string), + ifStatusByIndex: make(map[string]ifStatus), + ifIndexByIP: make(map[string]string), + ifNetmaskByIP: make(map[string]string), + l3InterfacesByIP: make(map[string]topologyL3Interface), + bridgePortToIf: make(map[string]string), + fdbEntries: make(map[string]*fdbEntry), + fdbIDToVlanID: make(map[string]string), + vlanIDToName: make(map[string]string), + stpPorts: make(map[string]*stpPortEntry), + arpEntries: make(map[string]*arpEntry), } } @@ -42,6 +43,7 @@ func (c *topologyCache) replaceWith(src *topologyCache) { c.ifStatusByIndex = src.ifStatusByIndex c.ifIndexByIP = src.ifIndexByIP c.ifNetmaskByIP = src.ifNetmaskByIP + c.l3InterfacesByIP = src.l3InterfacesByIP c.bridgePortToIf = src.bridgePortToIf c.fdbEntries = src.fdbEntries c.fdbIDToVlanID = src.fdbIDToVlanID diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_cache_test.go b/src/go/plugin/go.d/collector/snmp_topology/topology_cache_test.go index 42ee051b875b52..7a2961e01b8dbe 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_cache_test.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_cache_test.go @@ -225,6 +225,20 @@ func TestTopologyCache_UpdateIfIndexByIP_CollectsAllSNMPDeviceIPs(t *testing.T) require.Equal(t, "1", cache.ifIndexByIP["10.20.4.1"]) require.Equal(t, "2", cache.ifIndexByIP["10.20.4.2"]) require.Equal(t, "3", cache.ifIndexByIP["2001:db8::1"]) + require.Equal(t, "255.255.255.0", cache.ifNetmaskByIP["10.20.4.1"]) + require.Equal(t, "255.255.255.0", cache.ifNetmaskByIP["10.20.4.2"]) + require.Empty(t, cache.ifNetmaskByIP["2001:db8::1"]) + require.Equal(t, topologyL3Interface{ + IP: "10.20.4.1", + Netmask: "255.255.255.0", + IfIndex: "1", + }, cache.l3InterfacesByIP["10.20.4.1"]) + require.Equal(t, topologyL3Interface{ + IP: "10.20.4.2", + Netmask: "255.255.255.0", + IfIndex: "2", + }, cache.l3InterfacesByIP["10.20.4.2"]) + require.NotContains(t, cache.l3InterfacesByIP, "2001:db8::1") addrs := cache.localDevice.ManagementAddresses require.Len(t, addrs, 3) diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_l3_actor_resolver.go b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_actor_resolver.go new file mode 100644 index 00000000000000..3b263fdbfbdd5c --- /dev/null +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_actor_resolver.go @@ -0,0 +1,111 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmptopology + +import ( + "strings" +) + +type topologyL3ActorRef struct { + actorID string + match topologyMatch +} + +type topologyL3ActorResolver struct { + byActorID map[string]topologyL3ActorRef + byDeviceID map[string]topologyL3ActorRef + byIP map[string]topologyL3ActorRef +} + +func newTopologyL3ActorResolver(data *topologyData, snapshots []topologyObservationSnapshot) topologyL3ActorResolver { + resolver := topologyL3ActorResolver{ + byActorID: make(map[string]topologyL3ActorRef), + byDeviceID: make(map[string]topologyL3ActorRef), + byIP: make(map[string]topologyL3ActorRef), + } + if data == nil || len(data.Actors) == 0 { + return resolver + } + + managedActors := make([]topologyActor, 0, len(data.Actors)) + for _, actor := range data.Actors { + if !isManagedSNMPDeviceActor(actor) { + continue + } + managedActors = append(managedActors, actor) + ref := topologyL3ActorRef{ + actorID: strings.TrimSpace(actor.ActorID), + match: actor.Match, + } + if ref.actorID != "" { + resolver.byActorID[ref.actorID] = ref + } + if deviceID := topologyMetricValueString(actor.Attributes, "device_id"); deviceID != "" { + resolver.addUniqueDeviceID(deviceID, ref) + } + for _, ip := range normalizedMatchIPs(actor.Match) { + resolver.addUniqueIP(ip, ref) + } + } + + for _, snapshot := range snapshots { + deviceID := strings.TrimSpace(snapshot.localDeviceID) + if deviceID == "" { + continue + } + for _, actor := range managedActors { + if !matchLocalTopologyActor(actor.Match, snapshot.localDevice) { + continue + } + resolver.addUniqueDeviceID(deviceID, topologyL3ActorRef{ + actorID: strings.TrimSpace(actor.ActorID), + match: actor.Match, + }) + } + } + + return resolver +} + +func (r topologyL3ActorResolver) resolve(row topologyL3Interface) (topologyL3ActorRef, bool) { + if ref, ok := r.byDeviceID[strings.TrimSpace(row.DeviceID)]; ok && ref.actorID != "" { + return ref, true + } + if ref, ok := r.byActorID[strings.TrimSpace(row.DeviceID)]; ok && ref.actorID != "" { + return ref, true + } + if ref, ok := r.byIP[normalizeIPAddress(row.IP)]; ok && ref.actorID != "" { + return ref, true + } + return topologyL3ActorRef{}, false +} + +func (r topologyL3ActorResolver) addUniqueDeviceID(deviceID string, ref topologyL3ActorRef) { + deviceID = strings.TrimSpace(deviceID) + if deviceID == "" || ref.actorID == "" { + return + } + existing, ok := r.byDeviceID[deviceID] + if !ok { + r.byDeviceID[deviceID] = ref + return + } + if existing.actorID != "" && existing.actorID != ref.actorID { + r.byDeviceID[deviceID] = topologyL3ActorRef{} + } +} + +func (r topologyL3ActorResolver) addUniqueIP(ip string, ref topologyL3ActorRef) { + ip = normalizeIPAddress(ip) + if ip == "" || ref.actorID == "" { + return + } + existing, ok := r.byIP[ip] + if !ok { + r.byIP[ip] = ref + return + } + if existing.actorID != "" && existing.actorID != ref.actorID { + r.byIP[ip] = topologyL3ActorRef{} + } +} diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_l3_interfaces.go b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_interfaces.go new file mode 100644 index 00000000000000..73bc187757f1c1 --- /dev/null +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_interfaces.go @@ -0,0 +1,49 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmptopology + +import ( + "sort" + "strings" +) + +type topologyL3Interface struct { + DeviceID string + IP string + Netmask string + IfIndex string + IfName string + IfDescr string +} + +func (c *topologyCache) snapshotL3Interfaces(localDeviceID string) []topologyL3Interface { + if c == nil || len(c.l3InterfacesByIP) == 0 { + return nil + } + + ips := make([]string, 0, len(c.l3InterfacesByIP)) + for ip := range c.l3InterfacesByIP { + ips = append(ips, ip) + } + sort.Strings(ips) + + rows := make([]topologyL3Interface, 0, len(ips)) + for _, ip := range ips { + row := c.l3InterfacesByIP[ip] + row.DeviceID = strings.TrimSpace(localDeviceID) + row.IfIndex = strings.TrimSpace(row.IfIndex) + row.IP = normalizeIPAddress(row.IP) + row.Netmask = normalizeIPAddress(row.Netmask) + if row.IP == "" || row.Netmask == "" || row.IfIndex == "" { + continue + } + row.IfName = strings.TrimSpace(c.ifNamesByIndex[row.IfIndex]) + status := c.ifStatusByIndex[row.IfIndex] + row.IfDescr = strings.TrimSpace(status.ifDescr) + if row.IfDescr == "" { + row.IfDescr = row.IfName + } + rows = append(rows, row) + } + return rows +} diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_l3_links.go b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_links.go new file mode 100644 index 00000000000000..f57a19458366ca --- /dev/null +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_links.go @@ -0,0 +1,174 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmptopology + +import ( + "sort" + "strconv" + "strings" +) + +const topologyL3SubnetLinkType = "l3_subnet" + +type topologyL3EnrichmentStats struct { + subnetStats topologyL3SubnetBuildStats + emittedLinks int + suppressedUnresolvedActor int + suppressedSelfActor int + suppressedDuplicateLink int +} + +func applyTopologyL3SubnetEnrichment(data *topologyData, aggregate topologyObservationAggregate) topologyL3EnrichmentStats { + var stats topologyL3EnrichmentStats + if data == nil || len(aggregate.l3Interfaces) == 0 { + recordTopologyL3EnrichmentStats(data, stats) + return stats + } + + adjacencies, subnetStats := buildTopologyL3SubnetAdjacencies(aggregate.l3Interfaces) + stats.subnetStats = subnetStats + if len(adjacencies) == 0 { + recordTopologyL3EnrichmentStats(data, stats) + return stats + } + + resolver := newTopologyL3ActorResolver(data, aggregate.snapshots) + seen := existingTopologyL3LinkKeys(data.Links) + for _, adjacency := range adjacencies { + srcRef, ok := resolver.resolve(adjacency.A) + if !ok { + stats.suppressedUnresolvedActor++ + continue + } + dstRef, ok := resolver.resolve(adjacency.B) + if !ok { + stats.suppressedUnresolvedActor++ + continue + } + if srcRef.actorID == dstRef.actorID { + stats.suppressedSelfActor++ + continue + } + link := topologyL3SubnetLink(adjacency, srcRef, dstRef) + key := topologyL3SubnetLinkKey(link) + if _, exists := seen[key]; exists { + stats.suppressedDuplicateLink++ + continue + } + seen[key] = struct{}{} + data.Links = append(data.Links, link) + stats.emittedLinks++ + } + + sort.Slice(data.Links, func(i, j int) bool { + return topologyLinkSortKey(data.Links[i]) < topologyLinkSortKey(data.Links[j]) + }) + recordTopologyL3EnrichmentStats(data, stats) + recomputeTopologyLinkStats(data) + return stats +} + +func topologyL3SubnetLink(adjacency topologyL3SubnetAdjacency, srcRef, dstRef topologyL3ActorRef) topologyLink { + return topologyLink{ + Layer: "3", + Protocol: topologyL3SubnetLinkType, + LinkType: topologyL3SubnetLinkType, + Direction: "observed", + SrcActorID: srcRef.actorID, + DstActorID: dstRef.actorID, + Src: topologyLinkEndpoint{ + Match: srcRef.match, + Attributes: topologyL3EndpointAttributes(adjacency.A, adjacency), + }, + Dst: topologyLinkEndpoint{ + Match: dstRef.match, + Attributes: topologyL3EndpointAttributes(adjacency.B, adjacency), + }, + Metrics: map[string]any{ + "source": "ip_mib", + "inference": "shared_subnet", + "attachment_mode": "logical_l3_subnet", + "subnet": adjacency.Subnet, + "network": adjacency.Network, + "netmask": adjacency.Netmask, + "prefix": adjacency.Prefix, + }, + } +} + +func topologyL3EndpointAttributes(row topologyL3Interface, adjacency topologyL3SubnetAdjacency) map[string]any { + attrs := map[string]any{ + "ip": normalizeIPAddress(row.IP), + "subnet": adjacency.Subnet, + "network": adjacency.Network, + "netmask": adjacency.Netmask, + "prefix": adjacency.Prefix, + "source": "ip_mib", + } + if ifIndex := parseIndex(row.IfIndex); ifIndex > 0 { + attrs["if_index"] = ifIndex + } + if ifName := strings.TrimSpace(row.IfName); ifName != "" { + attrs["if_name"] = ifName + } + if ifDescr := strings.TrimSpace(row.IfDescr); ifDescr != "" { + attrs["if_descr"] = ifDescr + } + return attrs +} + +func existingTopologyL3LinkKeys(links []topologyLink) map[string]struct{} { + seen := make(map[string]struct{}) + for _, link := range links { + if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(link.LinkType, link.Protocol)), topologyL3SubnetLinkType) { + seen[topologyL3SubnetLinkKey(link)] = struct{}{} + } + } + return seen +} + +func topologyL3SubnetLinkKey(link topologyLink) string { + src := strings.TrimSpace(link.SrcActorID) + dst := strings.TrimSpace(link.DstActorID) + if src > dst { + src, dst = dst, src + } + return topologyL3SubnetLinkKeyParts( + src, + dst, + topologyMetricValueString(link.Metrics, "subnet"), + strconv.Itoa(intStatValue(link.Metrics["prefix"])), + ) +} + +func topologyL3SubnetLinkKeyParts(parts ...string) string { + var b strings.Builder + for _, part := range parts { + b.WriteString(strconv.Itoa(len(part))) + b.WriteByte(':') + b.WriteString(part) + } + return b.String() +} + +func recordTopologyL3EnrichmentStats(data *topologyData, stats topologyL3EnrichmentStats) { + if data == nil { + return + } + if data.Stats == nil { + data.Stats = make(map[string]any) + } + data.Stats["l3_subnet_candidate_subnets"] = stats.subnetStats.candidateSubnets + data.Stats["l3_subnet_candidate_links"] = stats.subnetStats.candidateLinks + data.Stats["l3_subnet_emitted_links"] = stats.emittedLinks + data.Stats["l3_subnet_suppressed_invalid"] = stats.subnetStats.suppressedInvalid + data.Stats["l3_subnet_suppressed_unsupported_prefix"] = stats.subnetStats.suppressedUnsupportedPrefix + data.Stats["l3_subnet_suppressed_duplicate_ip"] = stats.subnetStats.suppressedDuplicateIP + data.Stats["l3_subnet_suppressed_self_link"] = stats.subnetStats.suppressedSelfLink + data.Stats["l3_subnet_suppressed_unmatched"] = stats.subnetStats.suppressedUnmatched + data.Stats["l3_subnet_suppressed_multi_access"] = stats.subnetStats.suppressedMultiAccess + data.Stats["l3_subnet_suppressed_unresolved_actor"] = stats.suppressedUnresolvedActor + data.Stats["l3_subnet_suppressed_self_actor"] = stats.suppressedSelfActor + data.Stats["l3_subnet_suppressed_duplicate_link"] = stats.suppressedDuplicateLink + recomputeTopologyL3VisibleLinkStats(data) +} diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_l3_links_test.go b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_links_test.go new file mode 100644 index 00000000000000..e7887f08a05d64 --- /dev/null +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_links_test.go @@ -0,0 +1,178 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmptopology + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestTopologyL3ActorResolverSuppressesAmbiguousIP(t *testing.T) { + data := topologyData{ + Actors: []topologyActor{ + topologyL3ManagedActorForTest("router-a", nil, "198.51.100.1"), + topologyL3ManagedActorForTest("router-b", nil, "198.51.100.1"), + }, + } + + resolver := newTopologyL3ActorResolver(&data, nil) + _, ok := resolver.resolve(topologyL3Interface{ + DeviceID: "unknown-device", + IP: "198.51.100.1", + }) + + require.False(t, ok) +} + +func TestApplyTopologyL3SubnetEnrichmentSuppressesUnresolvedActor(t *testing.T) { + data := topologyData{ + Actors: []topologyActor{ + topologyL3ManagedActorForTest("device-a", nil, "198.51.100.1"), + }, + } + aggregate := topologyObservationAggregate{ + l3Interfaces: []topologyL3Interface{ + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", "2"), + l3InterfaceForTest("device-b", "198.51.100.2", "255.255.255.252", "3"), + }, + } + + stats := applyTopologyL3SubnetEnrichment(&data, aggregate) + + require.Empty(t, data.Links) + require.Equal(t, 1, stats.suppressedUnresolvedActor) + require.Equal(t, 1, data.Stats["l3_subnet_candidate_links"]) + require.Equal(t, 0, data.Stats["l3_subnet_emitted_links"]) + require.Equal(t, 0, data.Stats["l3_subnet_visible_links"]) + require.Equal(t, 1, data.Stats["l3_subnet_suppressed_unresolved_actor"]) +} + +func TestApplyTopologyL3SubnetEnrichmentSuppressesSelfActor(t *testing.T) { + data := topologyData{ + Actors: []topologyActor{ + topologyL3ManagedActorForTest("router-a", nil, "198.51.100.1", "198.51.100.2"), + }, + } + aggregate := topologyObservationAggregate{ + l3Interfaces: []topologyL3Interface{ + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", "2"), + l3InterfaceForTest("device-b", "198.51.100.2", "255.255.255.252", "3"), + }, + } + + stats := applyTopologyL3SubnetEnrichment(&data, aggregate) + + require.Empty(t, data.Links) + require.Equal(t, 1, stats.suppressedSelfActor) + require.Equal(t, 1, data.Stats["l3_subnet_candidate_links"]) + require.Equal(t, 0, data.Stats["l3_subnet_emitted_links"]) + require.Equal(t, 0, data.Stats["l3_subnet_visible_links"]) + require.Equal(t, 1, data.Stats["l3_subnet_suppressed_self_actor"]) +} + +func TestApplyTopologyL3SubnetEnrichmentSuppressesDuplicateLink(t *testing.T) { + existing := topologyLink{ + Protocol: topologyL3SubnetLinkType, + LinkType: topologyL3SubnetLinkType, + SrcActorID: "router-a", + DstActorID: "router-b", + Metrics: map[string]any{ + "subnet": "198.51.100.0/30", + "prefix": uint64(30), + }, + } + data := topologyData{ + Actors: []topologyActor{ + topologyL3ManagedActorForTest("router-a", nil, "198.51.100.1"), + topologyL3ManagedActorForTest("router-b", nil, "198.51.100.2"), + }, + Links: []topologyLink{existing}, + } + aggregate := topologyObservationAggregate{ + l3Interfaces: []topologyL3Interface{ + l3InterfaceForTest("router-a", "198.51.100.1", "255.255.255.252", "2"), + l3InterfaceForTest("router-b", "198.51.100.2", "255.255.255.252", "3"), + }, + } + + stats := applyTopologyL3SubnetEnrichment(&data, aggregate) + + require.Len(t, data.Links, 1) + require.Equal(t, existing, data.Links[0]) + require.Equal(t, 1, stats.suppressedDuplicateLink) + require.Equal(t, 1, data.Stats["l3_subnet_candidate_links"]) + require.Equal(t, 0, data.Stats["l3_subnet_emitted_links"]) + require.Equal(t, 1, data.Stats["l3_subnet_visible_links"]) + require.Equal(t, 1, data.Stats["l3_subnet_suppressed_duplicate_link"]) +} + +func TestTopologyL3SubnetLinkKeySeparatesDelimitedFields(t *testing.T) { + left := topologyLink{ + SrcActorID: "a|b", + DstActorID: "c", + Metrics: map[string]any{ + "subnet": "198.51.100.0/30", + "prefix": 30, + }, + } + right := topologyLink{ + SrcActorID: "a", + DstActorID: "b|c", + Metrics: map[string]any{ + "subnet": "198.51.100.0/30", + "prefix": 30, + }, + } + + require.NotEqual(t, topologyL3SubnetLinkKey(left), topologyL3SubnetLinkKey(right)) +} + +func TestApplyTopologyDepthFocusFilterKeepsIncidentL3SubnetLink(t *testing.T) { + data := topologyData{ + Stats: map[string]any{ + "l3_subnet_emitted_links": 1, + }, + Actors: []topologyActor{ + topologyL3ManagedActorForTest("router-a", nil, "198.51.100.1"), + topologyL3ManagedActorForTest("router-b", nil, "198.51.100.2"), + }, + Links: []topologyLink{ + { + Protocol: topologyL3SubnetLinkType, + LinkType: topologyL3SubnetLinkType, + SrcActorID: "router-a", + DstActorID: "router-b", + Metrics: map[string]any{ + "subnet": "198.51.100.0/30", + "prefix": 30, + }, + }, + }, + } + + applyTopologyDepthFocusFilter(&data, topologyQueryOptions{ + ManagedDeviceFocus: "ip:198.51.100.1", + Depth: 1, + InferenceStrategy: topologyInferenceStrategyFDBMinimumKnowledge, + MapType: topologyMapTypeHighConfidenceInferred, + EliminateNonIPInferred: true, + }) + + require.Len(t, data.Actors, 2) + require.Len(t, data.Links, 1) + require.Equal(t, topologyL3SubnetLinkType, data.Links[0].LinkType) + require.Equal(t, 1, data.Stats["l3_subnet_emitted_links"]) + require.Equal(t, 1, data.Stats["l3_subnet_visible_links"]) +} + +func topologyL3ManagedActorForTest(actorID string, attrs map[string]any, ips ...string) topologyActor { + return topologyActor{ + ActorID: actorID, + ActorType: "router", + Layer: "network", + Source: "snmp", + Match: topologyMatch{IPAddresses: ips}, + Attributes: attrs, + } +} diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_l3_subnets.go b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_subnets.go new file mode 100644 index 00000000000000..8bb8cf6df2cf92 --- /dev/null +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_subnets.go @@ -0,0 +1,172 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmptopology + +import ( + "net/netip" + "sort" + "strconv" + "strings" + + topologyengine "github.com/netdata/netdata/go/plugins/pkg/l2topology" +) + +type topologyL3SubnetAdjacency struct { + Subnet string + Network string + Netmask string + Prefix int + A topologyL3Interface + B topologyL3Interface +} + +type topologyL3SubnetBuildStats struct { + candidateSubnets int + candidateLinks int + suppressedInvalid int + suppressedUnsupportedPrefix int + suppressedDuplicateIP int + suppressedSelfLink int + suppressedUnmatched int + suppressedMultiAccess int +} + +type topologyL3SubnetGroup struct { + network netip.Addr + netmask netip.Addr + prefix int + rows []topologyL3Interface +} + +func buildTopologyL3SubnetAdjacencies(rows []topologyL3Interface) ([]topologyL3SubnetAdjacency, topologyL3SubnetBuildStats) { + var stats topologyL3SubnetBuildStats + if len(rows) == 0 { + return nil, stats + } + + groups := make(map[string]*topologyL3SubnetGroup) + for _, row := range rows { + group, ok := topologyL3SubnetGroupForInterface(row) + if !ok { + stats.suppressedInvalid++ + continue + } + if !topologyL3SubnetPrefixSupported(group.prefix) { + stats.suppressedUnsupportedPrefix++ + continue + } + key := topologyL3SubnetKey(group.network, group.prefix) + existing := groups[key] + if existing == nil { + existing = group + groups[key] = existing + } + existing.rows = append(existing.rows, row) + } + + keys := make([]string, 0, len(groups)) + for key := range groups { + keys = append(keys, key) + } + sort.Strings(keys) + + adjacencies := make([]topologyL3SubnetAdjacency, 0, len(keys)) + for _, key := range keys { + group := groups[key] + stats.candidateSubnets++ + sortTopologyL3Interfaces(group.rows) + if topologyL3SubnetHasDuplicateIP(group.rows) { + stats.suppressedDuplicateIP++ + continue + } + if len(group.rows) < 2 { + stats.suppressedUnmatched++ + continue + } + if len(group.rows) > 2 { + stats.suppressedMultiAccess++ + continue + } + if strings.TrimSpace(group.rows[0].DeviceID) == strings.TrimSpace(group.rows[1].DeviceID) { + stats.suppressedSelfLink++ + continue + } + adjacencies = append(adjacencies, topologyL3SubnetAdjacency{ + Subnet: group.network.String() + "/" + strconv.Itoa(group.prefix), + Network: group.network.String(), + Netmask: group.netmask.String(), + Prefix: group.prefix, + A: group.rows[0], + B: group.rows[1], + }) + stats.candidateLinks++ + } + + return adjacencies, stats +} + +func topologyL3SubnetGroupForInterface(row topologyL3Interface) (*topologyL3SubnetGroup, bool) { + deviceID := strings.TrimSpace(row.DeviceID) + if deviceID == "" || strings.TrimSpace(row.IfIndex) == "" { + return nil, false + } + ip, err := netip.ParseAddr(normalizeIPAddress(row.IP)) + if err != nil || !ip.Is4() { + return nil, false + } + netmask, err := netip.ParseAddr(normalizeIPAddress(row.Netmask)) + if err != nil || !netmask.Is4() { + return nil, false + } + network, ok := topologyengine.NetworkAddress(ip, netmask) + if !ok { + return nil, false + } + prefix, err := topologyengine.MaskToCIDRPrefix(netmask) + if err != nil { + return nil, false + } + return &topologyL3SubnetGroup{ + network: network, + netmask: netmask, + prefix: prefix, + }, true +} + +func topologyL3SubnetPrefixSupported(prefix int) bool { + return prefix == 30 || prefix == 31 +} + +func topologyL3SubnetKey(network netip.Addr, prefix int) string { + return network.String() + "/" + strconv.Itoa(prefix) +} + +func sortTopologyL3Interfaces(rows []topologyL3Interface) { + sort.Slice(rows, func(i, j int) bool { + return topologyL3InterfaceSortKey(rows[i]) < topologyL3InterfaceSortKey(rows[j]) + }) +} + +func topologyL3InterfaceSortKey(row topologyL3Interface) string { + return strings.Join([]string{ + strings.TrimSpace(row.DeviceID), + normalizeIPAddress(row.IP), + strings.TrimSpace(row.IfIndex), + }, "\x00") +} + +func topologyL3SubnetHasDuplicateIP(rows []topologyL3Interface) bool { + seen := make(map[string]string, len(rows)) + for _, row := range rows { + ip := normalizeIPAddress(row.IP) + if ip == "" { + continue + } + deviceID := strings.TrimSpace(row.DeviceID) + if owner, ok := seen[ip]; ok && owner != deviceID { + return true + } + seen[ip] = deviceID + } + return false +} diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_l3_subnets_test.go b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_subnets_test.go new file mode 100644 index 00000000000000..65a056f85fb40c --- /dev/null +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_l3_subnets_test.go @@ -0,0 +1,167 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmptopology + +import ( + "slices" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestBuildTopologyL3SubnetAdjacenciesIPv4PointToPoint(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("device-b", "198.51.100.2", "255.255.255.252", "3"), + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", "2"), + } + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + + require.Len(t, links, 1) + require.Equal(t, "198.51.100.0/30", links[0].Subnet) + require.Equal(t, "198.51.100.0", links[0].Network) + require.Equal(t, "255.255.255.252", links[0].Netmask) + require.Equal(t, 30, links[0].Prefix) + require.Equal(t, "device-a", links[0].A.DeviceID) + require.Equal(t, "device-b", links[0].B.DeviceID) + require.Equal(t, topologyL3SubnetBuildStats{ + candidateSubnets: 1, + candidateLinks: 1, + }, stats) +} + +func TestBuildTopologyL3SubnetAdjacenciesSupportsIPv4ThirtyOne(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("device-a", "198.51.100.0", "255.255.255.254", "2"), + l3InterfaceForTest("device-b", "198.51.100.1", "255.255.255.254", "3"), + } + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + + require.Len(t, links, 1) + require.Equal(t, "198.51.100.0/31", links[0].Subnet) + require.Equal(t, 31, links[0].Prefix) + require.Equal(t, topologyL3SubnetBuildStats{ + candidateSubnets: 1, + candidateLinks: 1, + }, stats) +} + +func TestBuildTopologyL3SubnetAdjacenciesSuppressesUnsupportedPrefix(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.0", "2"), + l3InterfaceForTest("device-b", "198.51.100.2", "255.255.255.0", "3"), + } + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + + require.Empty(t, links) + require.Equal(t, topologyL3SubnetBuildStats{ + suppressedUnsupportedPrefix: 2, + }, stats) +} + +func TestBuildTopologyL3SubnetAdjacenciesSuppressesDuplicateIPOwnership(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", "2"), + l3InterfaceForTest("device-b", "198.51.100.1", "255.255.255.252", "3"), + } + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + + require.Empty(t, links) + require.Equal(t, topologyL3SubnetBuildStats{ + candidateSubnets: 1, + suppressedDuplicateIP: 1, + }, stats) +} + +func TestBuildTopologyL3SubnetAdjacenciesSuppressesSameDeviceSelfLink(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", "2"), + l3InterfaceForTest("device-a", "198.51.100.2", "255.255.255.252", "3"), + } + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + + require.Empty(t, links) + require.Equal(t, topologyL3SubnetBuildStats{ + candidateSubnets: 1, + suppressedSelfLink: 1, + }, stats) +} + +func TestBuildTopologyL3SubnetAdjacenciesSuppressesUnmatchedSubnet(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", "2"), + } + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + + require.Empty(t, links) + require.Equal(t, topologyL3SubnetBuildStats{ + candidateSubnets: 1, + suppressedUnmatched: 1, + }, stats) +} + +func TestBuildTopologyL3SubnetAdjacenciesSuppressesMultiAccessRows(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", "2"), + l3InterfaceForTest("device-b", "198.51.100.2", "255.255.255.252", "3"), + l3InterfaceForTest("device-c", "198.51.100.3", "255.255.255.252", "4"), + } + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + + require.Empty(t, links) + require.Equal(t, topologyL3SubnetBuildStats{ + candidateSubnets: 1, + suppressedMultiAccess: 1, + }, stats) +} + +func TestBuildTopologyL3SubnetAdjacenciesDeterministic(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("device-d", "198.51.100.6", "255.255.255.252", "6"), + l3InterfaceForTest("device-b", "198.51.100.2", "255.255.255.252", "3"), + l3InterfaceForTest("device-c", "198.51.100.5", "255.255.255.252", "5"), + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", "2"), + } + reversed := slices.Clone(rows) + slices.Reverse(reversed) + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + reversedLinks, reversedStats := buildTopologyL3SubnetAdjacencies(reversed) + + require.Equal(t, links, reversedLinks) + require.Equal(t, stats, reversedStats) + require.Len(t, links, 2) + require.Equal(t, "198.51.100.0/30", links[0].Subnet) + require.Equal(t, "198.51.100.4/30", links[1].Subnet) +} + +func TestBuildTopologyL3SubnetAdjacenciesSuppressesInvalidRows(t *testing.T) { + rows := []topologyL3Interface{ + l3InterfaceForTest("", "198.51.100.1", "255.255.255.252", "2"), + l3InterfaceForTest("device-a", "not-an-ip", "255.255.255.252", "2"), + l3InterfaceForTest("device-a", "2001:db8::1", "ffff:ffff:ffff:ffff::", "2"), + l3InterfaceForTest("device-a", "198.51.100.1", "255.255.255.252", ""), + } + + links, stats := buildTopologyL3SubnetAdjacencies(rows) + + require.Empty(t, links) + require.Equal(t, topologyL3SubnetBuildStats{ + suppressedInvalid: 4, + }, stats) +} + +func l3InterfaceForTest(deviceID, ip, netmask, ifIndex string) topologyL3Interface { + return topologyL3Interface{ + DeviceID: deviceID, + IP: ip, + Netmask: netmask, + IfIndex: ifIndex, + } +} diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_registry_build.go b/src/go/plugin/go.d/collector/snmp_topology/topology_registry_build.go index 19427932f0d233..8ae195b9b3579c 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_registry_build.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_registry_build.go @@ -33,6 +33,7 @@ func buildSingleMapTopologySnapshot(aggregate topologyObservationAggregate, opti } augmentTopologySnapshotLocals(&data, aggregate.snapshots) applySNMPTopologyShapePolicies(&data, options) + applyTopologyL3SubnetEnrichment(&data, aggregate) applyTopologyDepthFocusFilter(&data, options) return data, true } @@ -68,6 +69,7 @@ func buildProbableTopologySnapshot(aggregate topologyObservationAggregate, optio augmentTopologySnapshotLocals(&probableData, aggregate.snapshots) applySNMPTopologyShapePolicies(&probableData, probableOptions) markProbableDeltaLinks(&strictData, &probableData) + applyTopologyL3SubnetEnrichment(&probableData, aggregate) applyTopologyDepthFocusFilter(&probableData, options) return probableData, true } diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_registry_observations.go b/src/go/plugin/go.d/collector/snmp_topology/topology_registry_observations.go index 6a7dcc34961704..9b28a8be998e2c 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_registry_observations.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_registry_observations.go @@ -74,16 +74,20 @@ func aggregateTopologyObservationSnapshots(snapshots []topologyObservationSnapsh } totalObservations := 0 + totalL3Interfaces := 0 for _, snapshot := range snapshots { totalObservations += len(snapshot.l2Observations) + totalL3Interfaces += len(snapshot.l3Interfaces) } aggregate := topologyObservationAggregate{ snapshots: snapshots, l2Observations: make([]topologyengine.L2Observation, 0, totalObservations), + l3Interfaces: make([]topologyL3Interface, 0, totalL3Interfaces), } for _, snapshot := range snapshots { aggregate.l2Observations = append(aggregate.l2Observations, snapshot.l2Observations...) + aggregate.l3Interfaces = append(aggregate.l3Interfaces, snapshot.l3Interfaces...) if aggregate.localDeviceID == "" { aggregate.localDeviceID = snapshot.localDeviceID } diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_registry_snapshot.go b/src/go/plugin/go.d/collector/snmp_topology/topology_registry_snapshot.go index 5c5b2f8a630d1d..357855965c83b8 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_registry_snapshot.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_registry_snapshot.go @@ -11,6 +11,7 @@ import ( type topologyObservationSnapshot struct { l2Observations []topologyengine.L2Observation + l3Interfaces []topologyL3Interface localDevice topologyDevice localDeviceID string agentID string @@ -20,6 +21,7 @@ type topologyObservationSnapshot struct { type topologyObservationAggregate struct { snapshots []topologyObservationSnapshot l2Observations []topologyengine.L2Observation + l3Interfaces []topologyL3Interface localDeviceID string agentID string collectedAt time.Time @@ -52,6 +54,7 @@ func (c *topologyCache) snapshotEngineObservations() (topologyObservationSnapsho return topologyObservationSnapshot{ l2Observations: []topologyengine.L2Observation{localObservation}, + l3Interfaces: c.snapshotL3Interfaces(localObservation.DeviceID), localDevice: local, localDeviceID: localObservation.DeviceID, agentID: strings.TrimSpace(c.agentID), diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_registry_test.go b/src/go/plugin/go.d/collector/snmp_topology/topology_registry_test.go index c3a598f836fb25..bc693688b1ec2b 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_registry_test.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_registry_test.go @@ -6,6 +6,7 @@ import ( "testing" "time" + topologyengine "github.com/netdata/netdata/go/plugins/pkg/l2topology" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -121,6 +122,72 @@ func TestTopologyRegistry_SnapshotSingleCacheKeepsLLDPUnidirectional(t *testing. require.Equal(t, 0, data.Stats["links_bidirectional"].(int)) } +func TestTopologyRegistry_DefaultMapEmitsL3SubnetForManagedRoutersWithoutLLDP(t *testing.T) { + registry := newTopologyRegistry() + + cacheA := newTopologyCache() + cacheA.updateTime = time.Now() + cacheA.lastUpdate = cacheA.updateTime + cacheA.agentID = "agent-test" + cacheA.localDevice = topologyDevice{ + ChassisID: "00:11:22:33:44:55", + ChassisIDType: "macAddress", + SysName: "router-a", + ManagementIP: "10.0.0.1", + } + cacheA.updateIfIndexByIP(map[string]string{ + tagTopoIfIndex: "2", + tagTopoIPAddr: "198.51.100.1", + tagTopoIPMask: "255.255.255.252", + }) + cacheA.updateIfNameByIndex(map[string]string{ + tagTopoIfIndex: "2", + tagTopoIfName: "wan0", + }) + + cacheB := newTopologyCache() + cacheB.updateTime = time.Now().Add(time.Second) + cacheB.lastUpdate = cacheB.updateTime + cacheB.agentID = "agent-test" + cacheB.localDevice = topologyDevice{ + ChassisID: "aa:bb:cc:dd:ee:ff", + ChassisIDType: "macAddress", + SysName: "router-b", + ManagementIP: "10.0.0.2", + } + cacheB.updateIfIndexByIP(map[string]string{ + tagTopoIfIndex: "7", + tagTopoIPAddr: "198.51.100.2", + tagTopoIPMask: "255.255.255.252", + }) + cacheB.updateIfNameByIndex(map[string]string{ + tagTopoIfIndex: "7", + tagTopoIfName: "wan7", + }) + + registry.register(cacheA) + registry.register(cacheB) + + data, ok := snapshotTopologyRegistryForTest(registry) + + require.True(t, ok) + require.Len(t, data.Actors, 2) + require.Len(t, data.Links, 1) + link := data.Links[0] + require.Equal(t, "3", link.Layer) + require.Equal(t, topologyL3SubnetLinkType, link.Protocol) + require.Equal(t, topologyL3SubnetLinkType, link.LinkType) + require.Equal(t, "observed", link.Direction) + require.Equal(t, "198.51.100.0/30", link.Metrics["subnet"]) + require.Equal(t, "shared_subnet", link.Metrics["inference"]) + require.Equal(t, "logical_l3_subnet", link.Metrics["attachment_mode"]) + require.Equal(t, "198.51.100.1", link.Src.Attributes["ip"]) + require.Equal(t, "198.51.100.2", link.Dst.Attributes["ip"]) + require.Equal(t, 1, data.Stats["l3_subnet_emitted_links"]) + require.Equal(t, 1, data.Stats["l3_subnet_visible_links"]) + require.Equal(t, 1, data.Stats["links_total"]) +} + func TestCompareCollapseActorPriorityPrefersNonEmptyActorID(t *testing.T) { left := topologyActor{ ActorID: "", @@ -293,6 +360,72 @@ func TestTopologyCache_SnapshotEngineObservationsUsesDirectLocalObservation(t *t require.Len(t, snapshot.l2Observations[0].CDPRemotes, 1) } +func TestTopologyCache_SnapshotEngineObservationsIncludesL3Interfaces(t *testing.T) { + cache := newTopologyCache() + cache.updateTime = time.Now() + cache.lastUpdate = cache.updateTime + cache.agentID = "agent-test" + cache.localDevice = topologyDevice{ + ChassisID: "00:11:22:33:44:55", + ChassisIDType: "macAddress", + SysName: "router-a", + ManagementIP: "10.0.0.1", + } + cache.updateIfIndexByIP(map[string]string{ + tagTopoIfIndex: "2", + tagTopoIPAddr: "198.51.100.1", + tagTopoIPMask: "255.255.255.252", + }) + cache.updateIfNameByIndex(map[string]string{ + tagTopoIfIndex: "2", + tagTopoIfName: "Gi0/2", + tagTopoIfDescr: "Uplink", + }) + cache.updateIfIndexByIP(map[string]string{ + tagTopoIfIndex: "3", + tagTopoIPAddr: "2001:db8::1", + }) + + snapshot, ok := cache.snapshotEngineObservations() + + require.True(t, ok) + require.Len(t, snapshot.l3Interfaces, 1) + require.Equal(t, topologyL3Interface{ + DeviceID: snapshot.localDeviceID, + IP: "198.51.100.1", + Netmask: "255.255.255.252", + IfIndex: "2", + IfName: "Gi0/2", + IfDescr: "Uplink", + }, snapshot.l3Interfaces[0]) +} + +func TestAggregateTopologyObservationSnapshotsIncludesL3Interfaces(t *testing.T) { + collectedAt := time.Now() + snapshots := []topologyObservationSnapshot{ + { + localDeviceID: "device-a", + agentID: "agent-a", + collectedAt: collectedAt, + l2Observations: []topologyengine.L2Observation{{ + DeviceID: "device-a", + }}, + l3Interfaces: []topologyL3Interface{{ + DeviceID: "device-a", + IP: "198.51.100.1", + Netmask: "255.255.255.252", + IfIndex: "2", + }}, + }, + } + + aggregate, ok := aggregateTopologyObservationSnapshots(snapshots) + + require.True(t, ok) + require.Len(t, aggregate.l3Interfaces, 1) + require.Equal(t, snapshots[0].l3Interfaces[0], aggregate.l3Interfaces[0]) +} + func TestTopologyRegistry_SnapshotReturnsFalseWithoutCollectedCaches(t *testing.T) { registry := newTopologyRegistry() cache := newTopologyCache() diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_shape_stats.go b/src/go/plugin/go.d/collector/snmp_topology/topology_shape_stats.go index d448c066503f8c..3164cd171bd9f5 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_shape_stats.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_shape_stats.go @@ -26,4 +26,21 @@ func recomputeTopologyLinkStats(data *topologyData) { } } data.Stats["links_probable"] = probable + recomputeTopologyL3VisibleLinkStats(data) +} + +func recomputeTopologyL3VisibleLinkStats(data *topologyData) { + if data == nil || data.Stats == nil { + return + } + if _, ok := data.Stats["l3_subnet_emitted_links"]; !ok { + return + } + count := 0 + for _, link := range data.Links { + if strings.EqualFold(strings.TrimSpace(firstNonEmptyString(link.LinkType, link.Protocol)), topologyL3SubnetLinkType) { + count++ + } + } + data.Stats["l3_subnet_visible_links"] = count } diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_shape_stats_test.go b/src/go/plugin/go.d/collector/snmp_topology/topology_shape_stats_test.go index 11956e6af53063..d3c40981ab265e 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_shape_stats_test.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_shape_stats_test.go @@ -51,3 +51,21 @@ func TestApplySNMPTopologyShapePolicies_EmitsStatsContractKeys(t *testing.T) { require.Equal(t, topologyMapTypeHighConfidenceInferred, data.Stats["map_type"]) require.Equal(t, topologyInferenceStrategySTPParentTree, data.Stats["inference_strategy"]) } + +func TestRecomputeTopologyLinkStatsRefreshesExistingL3VisibleCount(t *testing.T) { + data := &topologyData{ + Stats: map[string]any{ + "l3_subnet_emitted_links": 2, + }, + Links: []topologyLink{ + {Protocol: topologyL3SubnetLinkType, LinkType: topologyL3SubnetLinkType}, + {Protocol: "lldp", LinkType: "lldp"}, + }, + } + + recomputeTopologyLinkStats(data) + + require.Equal(t, 2, data.Stats["links_total"]) + require.Equal(t, 2, data.Stats["l3_subnet_emitted_links"]) + require.Equal(t, 1, data.Stats["l3_subnet_visible_links"]) +} diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_shape_values.go b/src/go/plugin/go.d/collector/snmp_topology/topology_shape_values.go index 70ee5cc2773b97..61221ad3e4768c 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_shape_values.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_shape_values.go @@ -72,8 +72,26 @@ func intStatValue(value any) int { switch typed := value.(type) { case int: return typed + case int8: + return int(typed) + case int16: + return int(typed) + case int32: + return int(typed) case int64: return int(typed) + case uint: + return uintStatValue(uint64(typed)) + case uint8: + return int(typed) + case uint16: + return int(typed) + case uint32: + return int(typed) + case uint64: + return uintStatValue(typed) + case float32: + return int(typed) case float64: return int(typed) case string: @@ -85,6 +103,13 @@ func intStatValue(value any) int { return 0 } +func uintStatValue(value uint64) int { + if value > uint64(^uint(0)>>1) { + return 0 + } + return int(value) +} + func topologyMetricValueString(metrics map[string]any, key string) string { if metrics == nil { return "" diff --git a/src/go/plugin/go.d/collector/snmp_topology/topology_shape_values_test.go b/src/go/plugin/go.d/collector/snmp_topology/topology_shape_values_test.go index f36d8dd0e44ab9..48a13f0c18227d 100644 --- a/src/go/plugin/go.d/collector/snmp_topology/topology_shape_values_test.go +++ b/src/go/plugin/go.d/collector/snmp_topology/topology_shape_values_test.go @@ -19,7 +19,9 @@ func TestTopologyShapeValues_ClassifyActorsAndValues(t *testing.T) { require.False(t, boolStatValue("0")) require.Equal(t, 7, intStatValue("7")) + require.Equal(t, 6, intStatValue(uint(6))) require.Equal(t, 5, intStatValue(int64(5))) + require.Equal(t, 30, intStatValue(uint64(30))) require.Equal(t, 0, intStatValue("nan")) require.Equal(t, "value", topologyMetricValueString(map[string]any{"key": " value "}, "key")) diff --git a/src/health/README.md b/src/health/README.md index ecea58b2dde41f..175aa377627b92 100644 --- a/src/health/README.md +++ b/src/health/README.md @@ -38,6 +38,7 @@ Your Netdata Agents treat notifications as **actions** triggered by alert status When you claim Agents to Netdata Cloud, they send their alert configurations and transitions to Cloud, which deduplicates them (merging multiple transitions from different Agents for the same host). Netdata Cloud triggers notifications centrally through its integrations (Slack, Microsoft Teams, Amazon SNS, PagerDuty, OpsGenie). Netdata Cloud's intelligent deduplication works by: + - **Consolidating multiple Agents** reporting the same alert - **Prioritizing highest severity**: CRITICAL > WARNING > CLEAR - **Creating unique keys**: Alert name + Instance + Node @@ -45,6 +46,7 @@ Netdata Cloud's intelligent deduplication works by: Your Agents and Netdata Cloud trigger actions independently using their own configurations and integrations. This design enables you to: + 1. **Maintain team independence**: Different teams run their own Parents with custom alerts 2. **Implement edge intelligence**: Critical alerts trigger automations directly on nodes 3. **Scale naturally**: Alert evaluation distributes with your infrastructure @@ -78,22 +80,26 @@ Each level operates independently while Netdata Cloud provides a coherent, dedup You configure Netdata alerts in 3 layers: -1. **Stock Alerts**: Netdata provides hundreds of alert definitions in `/usr/lib/netdata/conf.d/health.d` to detect common issues. Don't edit these directly - updates will overwrite your changes. -2. **Your Custom Alerts**: Create your own definitions in `/etc/netdata/health.d`. +1. **Stock Alerts**: Netdata provides hundreds of alert definitions in the stock `health.d/` directory to detect common issues. Don't edit these directly - updates will overwrite your changes. +2. **Your Custom Alerts**: Create your own definitions in your [Netdata config directory](/docs/netdata-agent/configuration/README.md) under `health.d/`. 3. **Dynamic UI Configuration**: Use Netdata dashboards to edit, add, enable, or disable alerts on any node through the streaming transport. +:::note +Config paths vary by install prefix. Run `sudo ./edit-config health.d/` from your Netdata config directory to resolve the correct user path automatically, or check the `[directories]` section of `netdata.conf` (keys `health config` and `stock health config`) for exact locations. +::: + ## Managing Notification Configuration You can configure notifications for any infrastructure node at 3 levels: -| Level | What It Evaluates | Where Notifications Come From | Use Case | Documentation | -|--------------------|-----------------------------|--------------------|-----------------------------------|-----------------------------------------------------------------------------------------------------------------------------| -| **Netdata Agent** | Local Metrics | Netdata Agent | Edge automation | [Agent integrations](https://learn.netdata.cloud/docs/alerts-&-notifications/notifications/agent-dispatched-notifications) | -| **Netdata Parent** | Local and Children Metrics | Netdata Parent | Edge automation | [Agent integrations](https://learn.netdata.cloud/docs/alerts-&-notifications/notifications/agent-dispatched-notifications) | -| **Netdata Cloud** | Receives Transitions | Netdata Cloud | Web-hooks, role/room based | [Cloud integrations](https://learn.netdata.cloud/docs/alerts-&-notifications/notifications/centralized-cloud-notifications) | +| Level | What It Evaluates | Where Notifications Come From | Use Case | Documentation | +|--------------------|----------------------------|-------------------------------|----------------------------|-----------------------------------------------------------------------------------------------------------------------------| +| **Netdata Agent** | Local Metrics | Netdata Agent | Edge automation | [Agent integrations](https://learn.netdata.cloud/docs/alerts-&-notifications/notifications/agent-dispatched-notifications) | +| **Netdata Parent** | Local and Children Metrics | Netdata Parent | Edge automation | [Agent integrations](https://learn.netdata.cloud/docs/alerts-&-notifications/notifications/agent-dispatched-notifications) | +| **Netdata Cloud** | Receives Transitions | Netdata Cloud | Web-hooks, role/room based | [Cloud integrations](https://learn.netdata.cloud/docs/alerts-&-notifications/notifications/centralized-cloud-notifications) | :::note -When using Parents and Cloud with default settings, you may receive duplicate email notifications. Agents send emails by default when an MTA exists on their systems. Disable email notifications on Agents and Parents when using Cloud by setting `SEND_EMAIL="NO"` in `/etc/netdata/health_alarm_notify.conf` [using `edit-config`](/docs/netdata-agent/configuration/README.md). +When using Parents and Cloud with default settings, you may receive duplicate email notifications. Agents send emails by default when an MTA exists on their systems. Disable email notifications on Agents and Parents when using Cloud by setting `SEND_EMAIL="NO"` in `health_alarm_notify.conf`, edited with `sudo ./edit-config health_alarm_notify.conf` from your [Netdata config directory](/docs/netdata-agent/configuration/README.md). ::: ### Best Practices for Large Deployments @@ -101,14 +107,16 @@ When using Parents and Cloud with default settings, you may receive duplicate em #### Central Alerting Strategy When you: + - Don't need edge automation (no scripts reacting to alerts) - Use highly available Parents for all nodes - Use Netdata Cloud (at least for Parents) Follow these steps: + 1. Disable health monitoring on child nodes 2. Share the same alert configuration across Parents (use git repo or CI/CD) -3. Disable Parent notifications (`SEND_EMAIL="NO"` in `/etc/netdata/health_alarm_notify.conf`) +3. Disable Parent notifications (`SEND_EMAIL="NO"` in `health_alarm_notify.conf`) 4. Keep only Cloud notifications This emulates traditional monitoring tools where you configure alerts centrally and dispatch notifications centrally. @@ -116,12 +124,14 @@ This emulates traditional monitoring tools where you configure alerts centrally #### Edge Flexible Alerting Strategy When you: + - Need edge automation (scale out, restart processes) - Use Parents - Use Cloud for all nodes Follow these steps: -- Disable stock alerts on children (`enable stock health configuration` to `no` in `/etc/netdata/netdata.conf` `[health]` section) + +- Disable stock alerts on children (set `enable stock health configuration` to `no` in the `[health]` section of `netdata.conf`; edit with `sudo ./edit-config netdata.conf`) - Configure only automation-required alerts on children - Keep stock alerts on Parents but disable notifications (`SEND_EMAIL="NO"`) - Keep only Cloud notifications @@ -140,11 +150,13 @@ This enables edge automation on children while maintaining central alerting cont ## Set Up Alerts via Netdata Agent 1. Open notification config: + ```bash sudo ./edit-config health_alarm_notify.conf ``` 2. Enable your method (example: email): + ```ini SEND_EMAIL="YES" DEFAULT_RECIPIENT_EMAIL="you@example.com" @@ -153,6 +165,7 @@ This enables edge automation on children while maintaining central alerting cont 3. Verify your system can send mail (sendmail, SMTP relay) 4. Restart the agent: + ```bash sudo systemctl restart netdata ``` @@ -162,17 +175,20 @@ This enables edge automation on children while maintaining central alerting cont ## Core Alerting Concepts Netdata supports two alert types: + - **Alarms**: Attach to specific instances (specific network interface, database instance) - **Templates**: Apply to all matching instances (all network interfaces, all databases) ### Alert Lifecycle and States Your alerts produce more than threshold checks. Each generates: + - **A value**: Combines metrics or other alerts using time-series lookups and expressions - **A unit**: Makes alerts meaningful ("seconds", "%", "requests/s") - **A name**: Identifies the alert This enables sophisticated alerts like: + - `out of disk space time: 450 seconds` - Predicts when disk fills based on current rate - `3xx redirects: 12.5 percent` - Calculates redirects as percentage of total - `response time vs yesterday: 150%` - Compares current to historical baseline @@ -181,21 +197,23 @@ This enables sophisticated alerts like: Your alerts exist in one of these states: -| State | Description | Trigger | -|-------------------|---------------------------------------------------|--------------------------------------------------------------------------| -| **CLEAR** | Normal - conditions exist but not triggered | Warning and critical conditions evaluate to zero | -| **WARNING** | Warning threshold exceeded | Warning condition evaluates to non-zero | -| **CRITICAL** | Critical threshold exceeded | Critical condition evaluates to non-zero | -| **UNDEFINED** | Cannot evaluate | No conditions defined, or value is NaN/Inf | -| **UNINITIALIZED** | Never evaluated | Alert just created | -| **REMOVED** | Alert deleted | Child disconnected, agent exit, or health reload | +| State | Description | Trigger | +|-------------------|---------------------------------------------|--------------------------------------------------| +| **CLEAR** | Normal - conditions exist but not triggered | Warning and critical conditions evaluate to zero | +| **WARNING** | Warning threshold exceeded | Warning condition evaluates to non-zero | +| **CRITICAL** | Critical threshold exceeded | Critical condition evaluates to non-zero | +| **UNDEFINED** | Cannot evaluate | No conditions defined, or value is NaN/Inf | +| **UNINITIALIZED** | Never evaluated | Alert just created | +| **REMOVED** | Alert deleted | Child disconnected, agent exit, or health reload | Alerts transition freely between states based on: + - **Calculated value** (including NaN, Inf, or valid numbers) - **Warning/critical conditions** (evaluation results) - **External events** (disconnections, reloads, exits) Key behaviors: + - Alerts jump directly from CLEAR to CRITICAL (no WARNING required) - WARNING and CRITICAL evaluate independently - Alerts return to appropriate state when data becomes available @@ -217,6 +235,7 @@ Your alerts perform complex calculations: ``` Examples: + ```yaml # Simple threshold calc: $used @@ -258,11 +277,13 @@ crit: $this > 90 OR $failures > 5 #### 3. Determine State Each condition evaluates to: + - NaN or Inf → UNDEFINED - Non-zero → RAISED - Zero → CLEAR Final status: + - Critical RAISED → **CRITICAL** (priority) - Warning RAISED → **WARNING** - Either CLEAR → **CLEAR** @@ -293,19 +314,24 @@ Data Collection Alert Evaluation Netdata prevents alert flapping through: #### 1. Hysteresis + ```yaml warn: ($status < $WARNING) ? ($this > 80) : ($this > 50) ``` + Triggers at 80, clears at 50, preventing flapping between 50-80. #### 2. Dynamic Delays + Alerts transition immediately in dashboards but notifications use exponential backoff. #### 3. Duration Requirements + ```yaml lookup: average -10m of used warn: $this > 80 ``` + Requires 10 minutes of data before triggering. ### Multi-Stage Alerts @@ -340,29 +366,29 @@ Variables resolve in order (first match wins): #### 1. Built-in Variables -| Variable | Description | Value | -|---------------------|---------------------------|--------------------| -| `$this` | Current calculated value | Result from lookup/calc | -| `$after` | Query start timestamp | Unix timestamp | -| `$before` | Query end timestamp | Unix timestamp | -| `$now` | Current time | Unix timestamp | -| `$last_collected_t` | Last collection time | Unix timestamp | -| `$update_every` | Collection frequency | Seconds | -| `$status` | Current status code | -2 to 3 | -| `$REMOVED` | Status constant | -2 | -| `$UNINITIALIZED` | Status constant | -1 | -| `$UNDEFINED` | Status constant | 0 | -| `$CLEAR` | Status constant | 1 | -| `$WARNING` | Status constant | 2 | -| `$CRITICAL` | Status constant | 3 | +| Variable | Description | Value | +|---------------------|--------------------------|-------------------------| +| `$this` | Current calculated value | Result from lookup/calc | +| `$after` | Query start timestamp | Unix timestamp | +| `$before` | Query end timestamp | Unix timestamp | +| `$now` | Current time | Unix timestamp | +| `$last_collected_t` | Last collection time | Unix timestamp | +| `$update_every` | Collection frequency | Seconds | +| `$status` | Current status code | -2 to 3 | +| `$REMOVED` | Status constant | -2 | +| `$UNINITIALIZED` | Status constant | -1 | +| `$UNDEFINED` | Status constant | 0 | +| `$CLEAR` | Status constant | 1 | +| `$WARNING` | Status constant | 2 | +| `$CRITICAL` | Status constant | 3 | #### 2. Dimension Values -| Syntax | Description | Example | -|------------------------------------|----------------------------------|------------------| -| `$dimension_name` | Last normalized value | `$used` | -| `$dimension_name_raw` | Last raw collected value | `$used_raw` | -| `$dimension_name_last_collected_t` | Collection timestamp | `$used_last_collected_t` | +| Syntax | Description | Example | +|------------------------------------|--------------------------|--------------------------| +| `$dimension_name` | Last normalized value | `$used` | +| `$dimension_name_raw` | Last raw collected value | `$used_raw` | +| `$dimension_name_last_collected_t` | Collection timestamp | `$used_last_collected_t` | ```yaml template: disk_usage_percent @@ -372,16 +398,19 @@ template: disk_usage_percent ``` #### 3. Chart Variables + ```yaml calc: $used > $threshold # If chart defines 'threshold' ``` #### 4. Host Variables + ```yaml warn: $connections > $max_connections * 0.8 # If host defines 'max_connections' ``` #### 5. Other Alerts + ```yaml # Alert 1 template: cpu_baseline @@ -394,6 +423,7 @@ template: cpu_check ``` #### 6. Cross-Context References + ```yaml template: disk_io_vs_iops on: disk.io @@ -410,6 +440,7 @@ When alerts reference variables matching multiple instances, Netdata uses label 3. **Select best match** - highest label overlap Example: Alert on `disk.io` (labels: `device=sda`, `mount=/data`) references `${disk.iops.reads}`: + - `disk.iops` for sda (labels match) → Score: 2 - `disk.iops` for sdb (no match) → Score: 0 Result: Uses sda's value @@ -417,6 +448,7 @@ Result: Uses sda's value ### Missing Data Handling During lookups with missing data: + - **All values NULL**: `$this` becomes `NaN` - **Some values exist**: Ignores NULL, continues calculation - **Dimension doesn't exist**: `$this` becomes `NaN` @@ -428,23 +460,27 @@ This handles intermittent collection, dynamic dimensions, and partial outages. Determine frequency by: 1. **With lookup**: Defaults to window duration + ```yaml lookup: average -5m # Evaluates every 5 minutes ``` 2. **Without lookup**: Set explicitly + ```yaml every: 10s calc: $system + $user ``` 3. **Custom interval**: Override default + ```yaml lookup: average -1m every: 10s # Check every 10s despite 1m window ``` Constraints: + - Cannot exceed data collection frequency - High frequency impacts performance - Use larger intervals with `unaligned` for efficiency @@ -453,7 +489,7 @@ Constraints: ### Netdata Assistant -The [Netdata Assistant](https://learn.netdata.cloud/docs/machine-learning-and-anomaly-detection/ai-powered-troubleshooting-assistant) provides AI-powered troubleshooting when alerts trigger: +The [Netdata Assistant](/docs/troubleshooting/troubleshoot.md) provides AI-powered troubleshooting when alerts trigger: 1. Click the alert in your dashboard 2. Press the Assistant button @@ -461,6 +497,81 @@ The [Netdata Assistant](https://learn.netdata.cloud/docs/machine-learning-and-an The Assistant window follows you through dashboards for easy reference while investigating. +### Missing or No Stock Alerts + +If your node has no stock alerts (the built-in alerts that ship with Netdata), check these common causes in order. The commands below use default install paths; adjust them if your Netdata was installed with a non-standard prefix (see [Managing Alert Configuration](#managing-alert-configuration) above). + +#### 1. Health monitoring disabled entirely + +When `enabled = no` is set in the `[health]` section of `netdata.conf`, the Agent stops evaluating all alerts. + +Check: + +```bash +grep 'enabled' /etc/netdata/netdata.conf +``` + +No output means the setting uses its default value (`yes`) — health monitoring is enabled. + +Restore: set `enabled = yes` in the `[health]` section (or remove the line to use the default), then restart the Agent: + +```bash +sudo systemctl restart netdata +``` + +#### 2. Stock health configuration disabled + +The `enable stock health configuration = no` setting in the `[health]` section of `netdata.conf` disables all stock alerts while keeping custom alerts active. + +Check: + +```bash +grep 'enable stock health configuration' /etc/netdata/netdata.conf +``` + +Restore: set `enable stock health configuration = yes` (or remove the line to use the default), then restart the Agent — `netdatacli reload-health` does not reload `netdata.conf`: + +```bash +sudo systemctl restart netdata +``` + +#### 3. File shadowing + +If a file in your user config directory has the same filename as a stock file (e.g., both contain `cpu.conf`), the stock file is **completely ignored** — only the user copy is loaded. If the user copy contains only a subset of the original alerts, the rest are missing. + +This is different from overriding individual alerts by name. With file shadowing, you must include **all** alerts you want from that file. See [Alert Configuration Ordering](/src/health/alert-configuration-ordering.md) for the conceptual explanation. + +Check — compare filenames between your user and stock directories: + +```bash +comm -12 <(ls /etc/netdata/health.d/ | sort) <(ls /usr/lib/netdata/conf.d/health.d/ | sort) +``` + +Restore: if the user copy is no longer needed, remove it from your user health config directory: + +```bash +sudo rm /etc/netdata/health.d/.conf +sudo netdatacli reload-health +``` + +If you need a modified version, ensure it includes all desired alerts from the stock file. + +#### 4. Dynamic UI/API configuration override + +Editing an alert through the Cloud dashboard or Agent UI creates a dynamic configuration (DynCfg) override that replaces the file-based definition. The override persists even if the underlying file changes. + +Restore: use the **Reset to default** option in the UI for each affected alert, or remove the dynamic config via the API. See [Overriding Stock Alerts](/src/health/overriding-stock-alerts.md) for full override documentation. + +#### Verify stock alerts are active + +After any fix, confirm the number of active alerts: + +```bash +curl -s "http://localhost:19999/api/v1/alarms?all" | jq '.alarms | to_entries[].value.name' | sort -u | wc -l +``` + +A healthy Netdata Agent typically has hundreds of stock alerts. If the count is very low, one of the causes above may still apply. + ### Community Resources Visit our [Alerts Troubleshooting space](https://community.netdata.cloud/c/alerts/28) for complex issues. Get help through [GitHub](https://github.com/netdata/netdata) or [Discord](https://discord.gg/kUk3nCmbtx). Share your solutions to help others. diff --git a/src/health/alert-configuration-ordering.md b/src/health/alert-configuration-ordering.md index c0f2977bae5eab..498f854527391e 100644 --- a/src/health/alert-configuration-ordering.md +++ b/src/health/alert-configuration-ordering.md @@ -77,18 +77,20 @@ Both have the **same name** (`disk_space_usage`), so precedence applies: Netdata loads alert configurations from two directories: -1. **User config** (loaded first): `/etc/netdata/health.d/` (default) -2. **Stock config** (loaded second): `/usr/lib/netdata/conf.d/health.d/` (default) +1. **User config** (loaded first): your `health.d/` directory under the [Netdata config directory](/docs/netdata-agent/configuration/README.md) +2. **Stock config** (loaded second): the stock `health.d/` directory -These paths can vary by installation. Check your `netdata.conf` `[directories]` section for exact paths. +:::note +Config paths vary by install prefix. Run `sudo ./edit-config health.d/` to resolve the correct user path automatically, or check the `[directories]` section of `netdata.conf` (keys `health config` and `stock health config`) for exact locations. +::: ### File Shadowing If a file with the **same name** exists in both directories, only the user file is loaded. The stock file is completely ignored. **Example:** -- Stock: `/usr/lib/netdata/conf.d/health.d/cpu.conf` -- User: `/etc/netdata/health.d/cpu.conf` +- Stock: `cpu.conf` in the stock config directory +- User: `cpu.conf` in your user config directory - Result: Only the user file is loaded This means if you copy a stock file to override it, you must include **all** alerts you want from that file, not just the ones you're modifying. diff --git a/src/health/overriding-stock-alerts.md b/src/health/overriding-stock-alerts.md index a7d120e8d32cc7..93f68595f909f7 100644 --- a/src/health/overriding-stock-alerts.md +++ b/src/health/overriding-stock-alerts.md @@ -4,12 +4,12 @@ This guide explains how to customize Netdata's stock alerts. User configurations ## Quick Reference -| Goal | Method | -|------|--------| +| Goal | Method | +|-------------------------------------|--------------------------------------| | Change thresholds for ALL instances | Create a template with the same name | -| Change thresholds for ONE instance | Create an alarm with the same name | -| Disable an alert completely | Use `enabled alarms` in netdata.conf | -| Silence notifications only | Set `to: silent` | +| Change thresholds for ONE instance | Create an alarm with the same name | +| Disable an alert completely | Use `enabled alarms` in netdata.conf | +| Silence notifications only | Set `to: silent` | ## Understanding Overrides @@ -21,11 +21,11 @@ See [Alert Configuration Ordering](/src/health/alert-configuration-ordering.md) ## Where to Put Your Overrides -**User config directory** (default): `/etc/netdata/health.d/` +Put your overrides in your [Netdata config directory](/docs/netdata-agent/configuration/README.md) under `health.d/` — files there survive upgrades. The **stock config directory** holds Netdata's built-in alert definitions and is replaced during updates. -Files here survive upgrades. Stock files in `/usr/lib/netdata/conf.d/health.d/` are replaced during updates. - -Check your `netdata.conf` `[directories]` section for exact paths on your system. +:::note +Config paths vary by install prefix. Run `sudo ./edit-config health.d/` from your Netdata config directory to resolve the correct user path automatically, or check the `[directories]` section of `netdata.conf` (keys `health config` and `stock health config`) for exact locations. +::: ## Method 1: Override All Instances (Template) @@ -33,24 +33,42 @@ Create a template with the same name to change thresholds for ALL instances. **Example: Raise CPU steal thresholds globally** -Stock alert in `/usr/lib/netdata/conf.d/health.d/cpu.conf`: +Stock alert from `cpu.conf` (in the stock config directory): + ```yaml -template: 20min_steal_cpu - on: system.cpu - lookup: average -20m unaligned of steal - units: % - every: 5m - warn: $this > (($status >= $WARNING) ? (5) : (10)) + template: 20min_steal_cpu + on: system.cpu + class: Latency + type: System + component: CPU +host labels: _os=linux + lookup: average -20m unaligned of steal + units: % + every: 5m + warn: $this > (($status >= $WARNING) ? (5) : (10)) + delay: down 1h multiplier 1.5 max 2h + summary: System CPU steal time + info: Average CPU steal time over the last 20 minutes + to: silent ``` -Your override in `/etc/netdata/health.d/my-overrides.conf`: +Your override (create it with `sudo ./edit-config health.d/my-overrides.conf`): + ```yaml -template: 20min_steal_cpu - on: system.cpu - lookup: average -20m unaligned of steal - units: % - every: 5m - warn: $this > (($status >= $WARNING) ? (10) : (20)) + template: 20min_steal_cpu + on: system.cpu + class: Latency + type: System + component: CPU +host labels: _os=linux + lookup: average -20m unaligned of steal + units: % + every: 5m + warn: $this > (($status >= $WARNING) ? (10) : (20)) + delay: down 1h multiplier 1.5 max 2h + summary: System CPU steal time + info: Average CPU steal time over the last 20 minutes + to: silent ``` **Why it works:** Same name + same context. Your template is processed first, creating the alert. The stock template is then skipped. @@ -64,6 +82,7 @@ Create an alarm to override thresholds for ONE specific instance while keeping s **Example: Different disk space threshold for `/mnt/data`** Stock template (applies to all disks): + ```yaml template: disk_space_usage on: disk.space @@ -72,7 +91,8 @@ template: disk_space_usage crit: $this < 10 ``` -Your override in `/etc/netdata/health.d/my-overrides.conf`: +Your override (create it with `sudo ./edit-config health.d/my-overrides.conf`): + ```yaml alarm: disk_space_usage on: disk_space._mnt_data @@ -82,6 +102,7 @@ alarm: disk_space_usage ``` **Why it works:** + - Both have the **same name** (`disk_space_usage`) - Your **alarm** targets the specific chart ID `disk_space._mnt_data` - Alarms are processed before templates (when names match) @@ -114,6 +135,7 @@ chart labels: mount_point=/mnt/data ``` Use labels when: + - You want to target multiple instances sharing a label - Chart IDs are dynamic or unpredictable @@ -122,7 +144,7 @@ Use labels when: If you want to modify many alerts in one stock file, copy it entirely: ```bash -cd /etc/netdata +# from your Netdata config directory: sudo ./edit-config health.d/cpu.conf ``` @@ -134,7 +156,7 @@ This means your copy must include ALL alerts you want—not just the ones you're ### Option A: Global Disable -In `/etc/netdata/netdata.conf`: +In your `netdata.conf` (edit it with `sudo ./edit-config netdata.conf`): ```ini [health] @@ -184,11 +206,13 @@ If `netdatacli` isn't available, send `SIGUSR2` to the Netdata process. ### Verify Your Override Check via API: + ```bash curl -s "http://localhost:19999/api/v1/alarms?all" | jq '.alarms | to_entries[] | select(.value.name == "20min_steal_cpu") | .value' ``` Key fields to check: + - `source`: confirms which config file is active (user override vs stock) - `lookup_*`: data query parameters - `warn`, `crit`: threshold expressions @@ -208,6 +232,7 @@ grep -i health /var/log/netdata/error.log | tail -20 ``` Common issues: + - Syntax errors in configuration - Alert name doesn't match exactly (case-sensitive) - File permissions prevent Netdata from reading your config @@ -224,6 +249,7 @@ Common issues: ### Both Stock and Override Alerts Appear This happens when matching criteria don't overlap. For example: + - Your override has `host labels: production` - Stock alert has no host labels restriction @@ -263,17 +289,20 @@ Both can coexist because they match different hosts. ### How do I find what stock alerts exist? -List all stock alert files: +List the stock alert files (the command below uses the default stock directory): + ```bash ls /usr/lib/netdata/conf.d/health.d/ ``` View a specific stock alert: + ```bash cat /usr/lib/netdata/conf.d/health.d/cpu.conf ``` -Or use the API to list all alert names: +Or use the API to list all alert names without dealing with paths: + ```bash curl -s "http://localhost:19999/api/v1/alarms?all" | jq '.alarms | to_entries[].value.name' | sort -u ``` @@ -292,13 +321,14 @@ template: my_custom_disk_alert ### What happens to my overrides after a Netdata upgrade? -User config files in `/etc/netdata/health.d/` are preserved. Stock files in `/usr/lib/netdata/conf.d/health.d/` are replaced. +User config files are preserved across upgrades. Stock files are replaced. Your overrides continue working. However, if a stock alert is renamed or removed in a new version, your override may become orphaned (still works, but no longer overriding anything). ### How do I override for multiple specific instances? Option 1: Create multiple alarms (one per instance): + ```yaml alarm: disk_space_usage on: disk_space._mnt_data @@ -310,6 +340,7 @@ alarm: disk_space_usage ``` Option 2: Use chart labels if instances share a label: + ```yaml template: disk_space_usage on: disk.space @@ -320,6 +351,7 @@ chart labels: storage_tier=bulk ### Can I see what overrides are currently active? Check which config files Netdata loaded: + ```bash # systemd journal: journalctl --namespace netdata -g "health.*load\|health.*read" --no-pager @@ -328,7 +360,8 @@ journalctl --namespace netdata -g "health.*load\|health.*read" --no-pager grep -iE "health.*(load|read)" /var/log/netdata/error.log ``` -Compare your active alert config vs stock: +Compare your active alert config vs stock (default locations shown): + ```bash # Your override cat /etc/netdata/health.d/my-overrides.conf