16 Commits
m1 ... m2

Author SHA1 Message Date
1ed97a540d Maintainability cleanup (collection paths): 63 findings to 0; fix flaky poller tests and a production timer race; M2 gate passed
Some checks failed
ci / lint (push) Has been cancelled
ci / test (ubuntu-24.04-arm) (push) Has been cancelled
ci / test (ubuntu-latest) (push) Has been cancelled
ci / postgres (push) Has been cancelled
ci / openapi (push) Has been cancelled
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:25:08 +00:00
5283921d6d Maintainability cleanup (API, app, CLI, fixtures): 167 lint findings to 0; app coverage 25->90%, config 0->90%; fixturegen never emits not-accessible columns
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:16:35 +00:00
e7c70ce466 M3 WP2: web UI (React 19, TanStack, uPlot TrafficGraph), contract-generated client, mock e2e + screenshots
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:13:27 +00:00
287fbb98bd Maintainability cleanup (core libraries): 223 lint findings to 0, oversized files split; fix apc-ups label columns and never poll text metrics
Migration files restructured with shared helpers; verified byte-identical SQLite schema vs previous commit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:11:15 +00:00
cce37313f1 M3 contract: facets, unreliable_reason, tiers
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 19:09:58 +00:00
2f6dcd5754 CI lint job; milestone definition of done includes clean lint
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:55:27 +00:00
f7b394585b Maintainability is a hard rule: standard, golangci-lint config, lint in every gate
- docs/MAINTAINABILITY.md: limits and architecture rules
- .golangci.yml (pinned v2.14.0) + file-size check; make lint runs first in every gate
- web/go.mod stops the Go tool from walking web/node_modules
- layering: Target -> internal/snmptarget, TableKey -> profile.Metric.TableKey; poller no longer imports discovery

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:54:32 +00:00
a037b406cb M2 integration: wiring, profile test, discover CLI/endpoint, per-profile fixtures and expect harness, gate-m2
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:50:02 +00:00
61b3de28dd M3: API contract written ahead of the WPs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:38:03 +00:00
cc9043b014 M4 WP2: MetricsQL rule evaluator with persisted state, stale/flap/ack/maintenance semantics
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:37:36 +00:00
4252b342fc M2 WP5: SMI MIB parser, profile new/record, separate nmon-librenms-import tool
Parses all 4,865 bundled MIBs; snmptranslate cross-check 200/200.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:25:40 +00:00
a71e3746f2 Docs: defer deployment playbook; screenshots per UI milestone
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:24:39 +00:00
22c2a6438c M2 WP4: profile-driven poller, varbind adaptation, backoff/degraded, reboot detection, trap receiver
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:22:13 +00:00
76a599d4e1 M2 WP3: full discovery, interface identity matcher, RMA, reattach, discovery scheduler
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:17:27 +00:00
b250f63608 M4 WP3: notification queue (webhook, SMTP, retry 1 h, incident IDs, redaction) and outbound allow-list
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:12:57 +00:00
b32c74c29a M2 prep: per-device snmp_port, device_components table and store contract
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Lx9FNLG2CqeBbVGGd9bFsP
2026-10-22 18:01:29 +00:00
360 changed files with 54653 additions and 2225 deletions

View File

@@ -1,6 +1,17 @@
name: ci
on: [push, pull_request]
jobs:
lint:
# Hard rule (docs/MAINTAINABILITY.md): any finding fails the build.
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with: { go-version: stable }
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: cd web && npm ci
- run: make lint
test:
strategy:
matrix:

138
.golangci.yml Normal file
View File

@@ -0,0 +1,138 @@
# Enforces docs/MAINTAINABILITY.md. A hard rule: `make lint` must be clean for any gate to pass.
version: "2"
run:
timeout: 10m
tests: true
# Report every finding: funlen and gocognit flag the same line and must both show.
issues:
max-issues-per-linter: 0
max-same-issues: 0
uniq-by-line: false
linters:
default: none
enable:
- govet
- errcheck
- staticcheck
- unused
- ineffassign
- funlen
- cyclop
- gocognit
- nestif
- dupl
- lll
- errorlint
- usestdlibvars
- misspell
- revive
- depguard
- forbidigo
- nolintlint
- unconvert
- bodyclose
- copyloopvar
settings:
funlen:
lines: 80
statements: 50
ignore-comments: true
cyclop:
max-complexity: 15
gocognit:
min-complexity: 20
nestif:
min-complexity: 4
dupl:
threshold: 150
lll:
line-length: 140
misspell:
# MikroTik's OS name and a profile name, not a typo of "routers".
ignore-rules: [routeros]
nolintlint:
require-explanation: true
require-specific: true
revive:
rules:
- name: exported
arguments: ["disableStutteringCheck"]
- name: package-comments
- name: var-naming
- name: indent-error-flow
- name: error-strings
- name: receiver-naming
- name: unexported-return
forbidigo:
analyze-types: true
forbid:
- pattern: '^time\.(Now|Sleep|After|Tick|NewTicker|NewTimer)$'
msg: "use internal/clock so tests control time (docs/MAINTAINABILITY.md)"
depguard:
rules:
orm-only-in-store:
files: ["$all", "!**/internal/store/**"]
deny:
- pkg: github.com/uptrace/bun
desc: SQL and the ORM live only in internal/store
- pkg: database/sql
desc: SQL lives only in internal/store
app-only-from-cmd:
files: ["$all", "!**/cmd/**"]
deny:
- pkg: github.com/v10networks/nmon/internal/app
desc: only cmd/* wires the application
api-only-from-app:
files: ["$all", "!**/internal/app/**", "!**/internal/api/**", "!$test"]
deny:
- pkg: github.com/v10networks/nmon/internal/api
desc: only internal/app mounts the API
collection-paths-independent:
files:
- "**/internal/poller/**"
- "**/internal/discovery/**"
- "**/internal/traps/**"
- "**/internal/ingest/**"
- "**/internal/checks/**"
- "**/internal/rollup/**"
- "**/internal/alert/**"
deny:
- pkg: github.com/v10networks/nmon/internal/poller
desc: collection paths share code via a lower package, not each other
- pkg: github.com/v10networks/nmon/internal/discovery
desc: collection paths share code via a lower package, not each other
- pkg: github.com/v10networks/nmon/internal/traps
desc: collection paths share code via a lower package, not each other
importer-not-in-nmon:
files: ["**/cmd/nmon/**"]
deny:
- pkg: github.com/v10networks/nmon/internal/librenms
desc: the LibreNMS importer is a separate tool (DEVIATIONS 9)
exclusions:
paths:
- web/node_modules
rules:
# Table-driven tests are long by nature; every other linter still applies.
- path: _test\.go
linters: [funlen, cyclop, gocognit, nestif, dupl, lll, forbidigo]
# The clock package wraps time; cmd/ and the test-only simulator may use real time.
- path: internal/clock/
linters: [forbidigo]
- path: cmd/
linters: [forbidigo]
# A package is allowed to import itself's own test helpers and itself.
- path: internal/(poller|discovery|traps)/.*_test\.go
linters: [depguard]
formatters:
enable:
- gofmt
- goimports
exclusions:
paths:
- web/node_modules

View File

@@ -4,7 +4,7 @@ LDFLAGS := -s -w
BIN := nmon
PG_DSN ?= postgres://nmon:nmon@localhost:55432/nmon?sslmode=disable
.PHONY: all build importer build-arm64 build-noembed fetch-vm web test test-integration test-pg test-v6only vet gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6 clean
.PHONY: all lint lint-go lint-web build importer build-arm64 build-noembed fetch-vm web test test-integration test-pg test-v6only vet gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6 clean
all: build
@@ -27,6 +27,18 @@ importer:
build-noembed:
CGO_ENABLED=0 $(GO) build -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/nmon
GOLANGCI := go run github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.14.0
# Hard rule (docs/MAINTAINABILITY.md): every gate runs this first and it must be clean.
lint: lint-go lint-web
lint-go:
$(GOLANGCI) run ./...
./scripts/lint-filesize.sh
lint-web:
@if [ -f web/package.json ]; then cd web && npm run lint; fi
vet:
$(GO) vet ./...

View File

@@ -0,0 +1,110 @@
// Command nmon-librenms-import turns LibreNMS OS definitions into draft nmon
// profiles. It runs offline, before nmon; nmon itself neither links nor exposes it.
//
// nmon-librenms-import --librenms <checkout> --out <dir> [--os junos,ios,...] [--mibs-dir <dir>]...
//
// The drafts hold only type: unverified metrics. Review them, copy the ones you
// want into <data-dir>/profiles/ and confirm them with `nmon profile test`.
package main
import (
"errors"
"flag"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"text/tabwriter"
"github.com/v10networks/nmon/internal/cliout"
"github.com/v10networks/nmon/internal/librenms"
)
type listFlag []string
func (l *listFlag) String() string { return strings.Join(*l, ",") }
func (l *listFlag) Set(v string) error {
for _, s := range strings.Split(v, ",") {
if s = strings.TrimSpace(s); s != "" {
*l = append(*l, s)
}
}
return nil
}
func main() {
if err := run(os.Args[1:]); err != nil {
fmt.Fprintln(os.Stderr, "nmon-librenms-import:", err)
os.Exit(1)
}
}
func run(args []string) error {
fs := flag.NewFlagSet("nmon-librenms-import", flag.ContinueOnError)
var (
root, out string
oses listFlag
mibDirs listFlag
)
fs.StringVar(&root, "librenms", "", "path of a LibreNMS checkout (required)")
fs.StringVar(&out, "out", "", "directory to write <os>.yaml drafts into (required)")
fs.Var(&oses, "os", "comma-separated LibreNMS OS names, e.g. junos,ios (default: every OS)")
fs.Var(&mibDirs, "mibs-dir", "extra MIB directory searched before the checkout's mibs/; repeatable")
fs.Usage = func() {
// Usage text is best-effort; a failed write to the terminal has nowhere to go.
_, _ = fmt.Fprintln(fs.Output(), "usage: nmon-librenms-import --librenms DIR --out DIR [--os a,b] [--mibs-dir DIR]...")
fs.PrintDefaults()
}
if err := fs.Parse(args); err != nil {
if errors.Is(err, flag.ErrHelp) {
return nil
}
return err
}
if root == "" || out == "" || fs.NArg() > 0 {
fs.Usage()
return errors.New("--librenms and --out are required")
}
imp, err := librenms.New(librenms.Options{LibreNMS: root, MIBDirs: mibDirs})
if err != nil {
return err
}
if len(oses) == 0 {
if oses, err = imp.OSList(); err != nil {
return err
}
}
if err := os.MkdirAll(out, 0o755); err != nil {
return err
}
return importAll(imp, oses, out, os.Stderr)
}
// importAll imports each OS, writes its draft into out and prints a summary
// table to w. Output failures count as failures of the run.
func importAll(imp *librenms.Importer, oses []string, out string, w io.Writer) error {
tw := tabwriter.NewWriter(w, 0, 4, 2, ' ', 0)
cw := cliout.New(tw)
cw.Println("OS\tPROFILE\tMAPPED\tLABELS\tSKIPPED")
failed := 0
for _, name := range oses {
d, err := imp.Import(name)
if err != nil {
cw.Printf("%s\tERROR\t\t\t%v\n", name, err)
failed++
continue
}
if err := os.WriteFile(filepath.Join(out, d.Name+".yaml"), d.YAML, 0o644); err != nil {
return err
}
cw.Printf("%s\t%s\t%d\t%d\t%d\n", name, d.Name, d.Mapped, d.Helpers, len(d.Skipped))
}
if err := errors.Join(tw.Flush(), cw.Err()); err != nil {
return fmt.Errorf("write summary: %w", err)
}
if failed > 0 {
return fmt.Errorf("%d of %d OS definitions could not be imported", failed, len(oses))
}
return nil
}

View File

@@ -0,0 +1,42 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/v10networks/nmon/internal/profile"
)
func TestRunWritesLoadableDrafts(t *testing.T) {
root := filepath.Join("..", "..", "internal", "librenms", "testdata", "lnms")
out := t.TempDir()
if err := run([]string{"--librenms", root, "--out", out, "--os", "acme"}); err != nil {
t.Fatal(err)
}
b, err := os.ReadFile(filepath.Join(out, "acme.yaml"))
if err != nil {
t.Fatal(err)
}
if _, err := profile.Parse("acme.yaml", b); err != nil {
t.Fatalf("%v\n%s", err, b)
}
if !strings.Contains(string(b), "type: unverified") {
t.Error("draft metrics must be unverified")
}
}
func TestRunErrors(t *testing.T) {
root := filepath.Join("..", "..", "internal", "librenms", "testdata", "lnms")
for _, args := range [][]string{
{},
{"--librenms", root},
{"--librenms", t.TempDir(), "--out", t.TempDir()},
{"--librenms", root, "--out", t.TempDir(), "--os", "nosuchos"},
} {
if err := run(args); err == nil {
t.Errorf("%v: expected an error", args)
}
}
}

124
cmd/nmon/discover.go Normal file
View File

@@ -0,0 +1,124 @@
package main
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/v10networks/nmon/internal/cliout"
)
func init() {
register("discover", "run discovery for a device now (asks a running nmon)", discoverCmd)
}
// discoverReport mirrors the API's DiscoverView.
type discoverReport struct {
Profile string `json:"profile"`
ProfileMissing string `json:"profile_missing"`
Partial bool `json:"partial"`
Tables []struct {
Name, Status, Detail string
Rows int
} `json:"tables"`
Matched int `json:"interfaces_matched"`
New int `json:"interfaces_new"`
Retired int `json:"interfaces_retired"`
Split int `json:"interfaces_split"`
Unretired int `json:"interfaces_unretired"`
Reviews int `json:"identity_reviews_opened"`
HardwareReplaced bool `json:"hardware_replaced"`
Warnings []string `json:"warnings"`
}
func discoverCmd(ctx context.Context, args []string) error {
fs := profileFlagSet("discover", "discover <slug> [--api URL]")
fs.Usage = func() {
// Usage text is best-effort; a failed write to the terminal has nowhere to go.
_, _ = fmt.Fprintln(os.Stderr, "usage: nmon discover <slug> [--api URL] [--data-dir DIR]\n\n"+
"Asks the running nmon at --api to discover the device now and prints what changed.")
fs.PrintDefaults()
}
var api, dataDir string
var timeout time.Duration
fs.StringVar(&api, "api", "http://[::1]:8443", "base URL of the running nmon")
fs.StringVar(&dataDir, "data-dir", "", "accepted for symmetry with serve; the API of the running server is used")
fs.DurationVar(&timeout, "timeout", 60*time.Second, "how long to wait for the discovery")
pos, err := parseInterspersed(fs, args)
if err != nil {
return flagErr(err)
}
if len(pos) != 1 {
return fmt.Errorf("usage: nmon discover <slug> [--api URL]")
}
rep, err := requestDiscover(ctx, api, pos[0], timeout)
if err != nil {
return err
}
return rep.print(os.Stdout, pos[0])
}
func requestDiscover(ctx context.Context, api, slug string, timeout time.Duration) (*discoverReport, error) {
ctx, cancel := context.WithTimeout(ctx, timeout)
defer cancel()
u := strings.TrimRight(api, "/") + "/api/v1/devices/" + url.PathEscape(slug) + "/discover"
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u, bytes.NewReader(nil))
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("cannot reach nmon at %s (is it running? use --api): %w", api, err)
}
defer func() { _ = resp.Body.Close() }() // body fully read below; a close error changes nothing
body, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<20))
if resp.StatusCode != http.StatusOK {
var p struct{ Title, Detail string }
_ = json.Unmarshal(body, &p)
msg := p.Detail
if msg == "" {
msg = p.Title
}
return nil, fmt.Errorf("%s: %d %s", slug, resp.StatusCode, msg)
}
var rep discoverReport
if err := json.Unmarshal(body, &rep); err != nil {
return nil, err
}
return &rep, nil
}
func (r *discoverReport) print(out io.Writer, slug string) error {
w := cliout.New(out)
w.Printf("discovered %s: profile %s\n", slug, r.Profile)
if r.ProfileMissing != "" {
w.Printf(" profile %q no longer exists; generic is in use\n", r.ProfileMissing)
}
w.Printf(" interfaces: %d matched, %d new, %d retired, %d unretired, %d split\n",
r.Matched, r.New, r.Retired, r.Unretired, r.Split)
if r.Reviews > 0 {
w.Printf(" %d identity review(s) opened: nothing was merged automatically\n", r.Reviews)
}
if r.HardwareReplaced {
w.Println(" hardware replaced: chassis serial changed, history continues (review opened)")
}
for _, t := range r.Tables {
if t.Status != "ok" {
w.Printf(" table %s: %s %s\n", t.Name, t.Status, t.Detail)
}
}
if r.Partial {
w.Println(" PARTIAL: a table overran its limit; its previous result was kept")
}
for _, x := range r.Warnings {
w.Printf(" warning: %s\n", x)
}
return w.Err()
}

View File

@@ -44,7 +44,7 @@ func migrate(ctx context.Context, args []string) error {
if err != nil {
return err
}
defer st.Close()
defer func() { _ = st.Close() }() // migrations are committed; a close error changes nothing
if action == "down" {
if err := st.Rollback(ctx); err != nil {
return err

78
cmd/nmon/profile.go Normal file
View File

@@ -0,0 +1,78 @@
package main
import (
"context"
"flag"
"fmt"
"io"
"os"
"strings"
)
func init() {
register("profile", "test, record and draft device profiles", profileCmd)
}
const profileUsage = `usage: nmon profile <subcommand> [flags]
subcommands:
new compile MIB objects into draft profile metrics (--mib MODULE)
record read-only SNMP walk of a device into a redacted .snmprec file
test discovery plus one poll with the matching profile
`
func profileCmd(ctx context.Context, args []string) error {
if len(args) == 0 || args[0] == "-h" || args[0] == "--help" || args[0] == "help" {
fmt.Fprint(os.Stderr, profileUsage)
if len(args) == 0 {
os.Exit(2)
}
return nil
}
switch args[0] {
case "new":
return profileNew(ctx, args[1:], os.Stdout)
case "record":
return profileRecord(ctx, args[1:], os.Stdout)
case "test":
return profileTest(ctx, args[1:], os.Stdout)
}
fmt.Fprint(os.Stderr, profileUsage)
return fmt.Errorf("profile: unknown subcommand %q", args[0])
}
// listFlag is a repeatable string flag that also accepts comma-separated values.
type listFlag []string
func (l *listFlag) String() string { return strings.Join(*l, ",") }
func (l *listFlag) Set(v string) error {
for _, s := range strings.Split(v, ",") {
if s = strings.TrimSpace(s); s != "" {
*l = append(*l, s)
}
}
return nil
}
// parseInterspersed parses flags that may come before or after positional arguments.
func parseInterspersed(fs *flag.FlagSet, args []string) ([]string, error) {
var pos []string
for {
if err := fs.Parse(args); err != nil {
return nil, err
}
args = fs.Args()
if len(args) == 0 {
return pos, nil
}
pos = append(pos, args[0])
args = args[1:]
}
}
// profileFlagSet returns a FlagSet that prints its usage to stderr.
func profileFlagSet(name, usage string) *flag.FlagSet {
fs := newFlagSet("profile "+name, usage)
fs.SetOutput(io.Writer(os.Stderr))
return fs
}

483
cmd/nmon/profile_new.go Normal file
View File

@@ -0,0 +1,483 @@
package main
import (
"context"
"fmt"
"io"
"os"
"path/filepath"
"regexp"
"slices"
"sort"
"strconv"
"strings"
"github.com/v10networks/nmon/internal/mib"
)
const defaultSystemMIBs = "/usr/share/nmon/mibs"
// mibSearchPath returns the directories to read MIBs from: explicit --mibs-dir
// values, else <data-dir>/mibs followed by the system location.
func mibSearchPath(explicit []string, dataDir string) ([]string, error) {
if len(explicit) > 0 {
for _, d := range explicit {
if st, err := os.Stat(d); err != nil || !st.IsDir() {
return nil, fmt.Errorf("--mibs-dir %s: not a directory", d)
}
}
return explicit, nil
}
var out []string
var cands []string
if dataDir != "" {
cands = append(cands, filepath.Join(dataDir, "mibs"))
}
cands = append(cands, defaultSystemMIBs)
for _, d := range cands {
if st, err := os.Stat(d); err == nil && st.IsDir() {
out = append(out, d)
}
}
if len(out) == 0 {
return nil, fmt.Errorf("no MIB directory found (looked in %s); pass --mibs-dir", strings.Join(cands, ", "))
}
return out, nil
}
func profileNew(_ context.Context, args []string, stdout io.Writer) error {
fs := profileFlagSet("new",
"profile new --mib MODULE [--table NAME] [--objects a,b] [--mibs-dir DIR]... [--data-dir DIR] [--out FILE]")
var (
module, tableName, out, dataDir string
objects, mibsDirs listFlag
)
fs.StringVar(&module, "mib", "", "MIB module to compile, e.g. IF-MIB (required)")
fs.StringVar(&tableName, "table", "", "compile every readable column of this table")
fs.Var(&objects, "objects", "comma-separated object names (NAME or MODULE::NAME); repeatable")
fs.Var(&mibsDirs, "mibs-dir",
"directory of MIB files, searched recursively; repeatable (default <data-dir>/mibs, then "+defaultSystemMIBs+")")
fs.StringVar(&dataDir, "data-dir", "", "nmon data folder; its mibs/ subfolder is searched")
fs.StringVar(&out, "out", "", "write the draft here instead of stdout")
rest, err := parseInterspersed(fs, args)
if err != nil {
return flagErr(err)
}
if len(rest) > 0 {
return fmt.Errorf("unexpected argument %q", rest[0])
}
if module == "" {
return fmt.Errorf("--mib is required")
}
path, err := mibSearchPath(mibsDirs, dataDir)
if err != nil {
return err
}
l := mib.NewLoader(path...)
draft, err := draftProfile(l, draftSpec{Module: module, Table: tableName, Objects: objects})
if err != nil {
return err
}
if out != "" {
return os.WriteFile(out, draft, 0o644)
}
_, err = stdout.Write(draft)
return err
}
type draftSpec struct {
Module string
Table string
Objects []string
}
// unitMap maps UNITS clauses (lower case) to the profile unit vocabulary, with
// the scale that converts the MIB's unit to it.
var unitMap = map[string]struct {
unit string
scale float64
}{
"seconds": {"seconds", 0}, "second": {"seconds", 0}, "secs": {"seconds", 0}, "sec": {"seconds", 0},
"minutes": {"minutes", 0}, "minute": {"minutes", 0},
"degrees celsius": {"celsius", 0}, "degrees centigrade": {"celsius", 0}, "celsius": {"celsius", 0},
"degrees c": {"celsius", 0}, "deg c": {"celsius", 0},
"volts": {"volts", 0}, "volt": {"volts", 0}, "millivolts": {"volts", 0.001}, "mv": {"volts", 0.001},
"amperes": {"amps", 0}, "amps": {"amps", 0}, "amp": {"amps", 0}, "milliamperes": {"amps", 0.001},
"milliamps": {"amps", 0.001}, "ma": {"amps", 0.001},
"watts": {"watts", 0}, "watt": {"watts", 0}, "milliwatts": {"watts", 0.001}, "mw": {"watts", 0.001},
"dbm": {"dbm", 0},
"rpm": {"rpm", 0}, "revolutions per minute": {"rpm", 0},
"percent": {"percent", 0}, "%": {"percent", 0}, "percentage": {"percent", 0},
"bytes": {"bytes", 0}, "octets": {"octets", 0}, "kilobytes": {"bytes", 1024}, "megabytes": {"bytes", 1048576},
"bits": {"bits", 0}, "bits per second": {"bps", 0}, "bps": {"bps", 0}, "kilobits per second": {"bps", 1000},
"packets": {"packets", 0}, "errors": {"errors", 0},
"hertz": {"hertz", 0}, "hz": {"hertz", 0},
}
var hintRE = regexp.MustCompile(`^d-([0-9]+)$`)
type draftMetric struct {
obj *mib.Object
label string // index_label column
labelAs string
comments []string
typ string
unit string
scale float64
enum map[int]string
}
// draftProfile compiles the selected objects into a complete profile document
// whose unit and interval are TODO so that profile.Parse refuses it until a human fills them in.
func draftProfile(l *mib.Loader, spec draftSpec) ([]byte, error) {
m, err := l.Load(spec.Module)
if err != nil {
return nil, err
}
objs, err := selectObjects(l, m, spec)
if err != nil {
return nil, err
}
return []byte(renderDraft(spec, m, draftMetrics(l, m, objs))), nil
}
// selectObjects picks the objects to compile: a table's readable columns, the
// named objects, or (when neither is given) every readable scalar and column.
// The result is sorted by OID.
func selectObjects(l *mib.Loader, m *mib.Module, spec draftSpec) ([]*mib.Object, error) {
chosen := map[string]*mib.Object{}
add := func(o *mib.Object) { chosen[o.Module+"::"+o.Name] = o }
if spec.Table != "" {
cols, err := tableColumns(m, spec)
if err != nil {
return nil, err
}
for _, o := range cols {
add(o)
}
}
named, err := namedObjects(l, spec)
if err != nil {
return nil, err
}
for _, o := range named {
add(o)
}
if spec.Table == "" && len(spec.Objects) == 0 {
for _, o := range allReadable(m) {
add(o)
}
}
if len(chosen) == 0 {
return nil, fmt.Errorf("%s: no readable scalar or column objects selected", spec.Module)
}
objs := make([]*mib.Object, 0, len(chosen))
for _, o := range chosen {
objs = append(objs, o)
}
sort.Slice(objs, func(i, j int) bool { return oidLess(objs[i].OID, objs[j].OID) })
return objs, nil
}
// allReadable lists every current, readable scalar and column of m.
func allReadable(m *mib.Module) []*mib.Object {
var out []*mib.Object
for _, o := range m.Objects {
if (o.Kind == mib.KindScalar || o.Kind == mib.KindColumn) && readable(o) && o.Status != "obsolete" && o.OID != "" {
out = append(out, o)
}
}
return out
}
// tableColumns lists the readable columns of spec.Table.
func tableColumns(m *mib.Module, spec draftSpec) ([]*mib.Object, error) {
tb := m.Lookup(spec.Table)
if tb == nil || tb.Kind != mib.KindTable {
return nil, fmt.Errorf("%s: no table %q (tables are %s)", spec.Module, spec.Table, strings.Join(tableNames(m), ", "))
}
var cols []*mib.Object
for _, o := range m.Objects {
if o.Kind == mib.KindColumn && o.Table == spec.Table && readable(o) {
cols = append(cols, o)
}
}
return cols, nil
}
// namedObjects resolves --objects entries; a bare NAME belongs to spec.Module.
func namedObjects(l *mib.Loader, spec draftSpec) ([]*mib.Object, error) {
var out []*mib.Object
for _, n := range spec.Objects {
q := n
if !strings.Contains(q, "::") {
q = spec.Module + "::" + n
}
o, err := l.Object(q)
if err != nil {
return nil, err
}
if o.Kind != mib.KindScalar && o.Kind != mib.KindColumn {
return nil, fmt.Errorf("%s is a %s, not a scalar or table column", q, o.Kind)
}
out = append(out, o)
}
return out, nil
}
// draftMetrics converts objs to draft metrics. Label columns used by
// index_label must exist as metrics of the profile, so the ones not already
// selected are appended, sorted by OID.
func draftMetrics(l *mib.Loader, m *mib.Module, objs []*mib.Object) []draftMetric {
labels := map[string]*mib.Object{}
var metrics []draftMetric
have := map[string]bool{}
for _, o := range objs {
have[o.Name] = true
}
for _, o := range objs {
dm := convert(o)
if o.Kind == mib.KindColumn {
if lo := setLabel(&dm, l, m, o); lo != nil && !have[lo.Name] {
labels[lo.Name] = lo
}
}
metrics = append(metrics, dm)
}
var extra []*mib.Object
for _, o := range labels {
extra = append(extra, o)
}
sort.Slice(extra, func(i, j int) bool { return oidLess(extra[i].OID, extra[j].OID) })
for _, o := range extra {
dm := convert(o)
dm.comments = append(dm.comments, "added as the index_label column of the metrics above")
metrics = append(metrics, dm)
}
return metrics
}
// setLabel fills dm's index_label from labelFor. It returns the label column
// when the profile must carry it as a metric of its own (a component label), else nil.
func setLabel(dm *draftMetric, l *mib.Loader, m *mib.Module, o *mib.Object) *mib.Object {
col, as, note := labelFor(l, m, o)
dm.label, dm.labelAs = col, as
if note != "" {
dm.comments = append(dm.comments, note)
}
if as != "component" {
return nil
}
return m.Lookup(col)
}
// renderDraft writes the profile YAML document.
func renderDraft(spec draftSpec, m *mib.Module, metrics []draftMetric) string {
var b strings.Builder
name := "draft-" + strings.ToLower(spec.Module)
fmt.Fprintf(&b, "# DRAFT compiled by `nmon profile new` from %s (%s).\n", spec.Module, filepath.Base(m.File))
fmt.Fprintf(&b, "# Save as %s.yaml. Replace every TODO (unit, interval, index_label) and add a match: section.\n", name)
b.WriteString("# The loader rejects TODO on purpose, so an unreviewed draft can never be polled.\n")
fmt.Fprintf(&b, "name: %s\nextends: generic\nprovenance: %q\nmetrics:\n", name,
"drafted by nmon profile new from "+spec.Module+"; review before use")
for _, dm := range metrics {
writeDraftMetric(&b, dm)
}
return b.String()
}
func tableNames(m *mib.Module) []string {
var out []string
for _, o := range m.Objects {
if o.Kind == mib.KindTable {
out = append(out, o.Name)
}
}
return out
}
func readable(o *mib.Object) bool {
switch o.Access {
case "read-only", "read-write", "read-create", "mandatory", "":
return o.Access != "" || o.Kind == mib.KindScalar
}
return false
}
func oidLess(a, b string) bool {
as, bs := strings.Split(a, "."), strings.Split(b, ".")
for i := 0; i < len(as) && i < len(bs); i++ {
x, _ := strconv.Atoi(as[i])
y, _ := strconv.Atoi(bs[i])
if x != y {
return x < y
}
}
return len(as) < len(bs)
}
// convert maps one MIB object to a draft metric.
func convert(o *mib.Object) draftMetric {
dm := draftMetric{obj: o, unit: "TODO"}
if o.TC != "" || o.DisplayHint != "" {
dm.comments = append(dm.comments, conventionNote(o))
}
applyBaseType(&dm, o)
if o.Units != "" {
if u, ok := unitMap[strings.ToLower(o.Units)]; ok && dm.typ != "status" && dm.typ != "text" {
dm.unit, dm.scale = u.unit, u.scale
} else {
dm.comments = append(dm.comments, "UNITS "+strconv.Quote(o.Units)+" is not in the unit vocabulary")
}
}
return dm
}
// conventionNote describes the textual convention and DISPLAY-HINT, and
// suggests a scale when the hint implies decimal places ("d-2").
func conventionNote(o *mib.Object) string {
c := "textual convention " + o.TC
if o.TC == "" {
c = "no textual convention"
}
if o.DisplayHint != "" {
c += ", DISPLAY-HINT " + strconv.Quote(o.DisplayHint)
}
if mm := hintRE.FindStringSubmatch(o.DisplayHint); mm != nil {
n, _ := strconv.Atoi(mm[1])
s := "0." + strings.Repeat("0", n-1) + "1"
if n == 0 {
s = "1"
}
c += "; the value has " + mm[1] + " implied decimal place(s), suggest scale: " + s
}
return c
}
// applyBaseType sets the metric type (and unit where the type implies one)
// from the object's SMI base type.
func applyBaseType(dm *draftMetric, o *mib.Object) {
switch o.BaseType {
case mib.BaseCounter64:
dm.typ = "counter64"
case mib.BaseCounter32:
dm.typ = "counter32"
case mib.BaseGauge32, mib.BaseUnsigned32, mib.BaseInteger32, mib.BaseInteger:
if len(o.Enum) > 0 {
dm.typ, dm.unit, dm.enum = "status", "enum", o.Enum
} else {
dm.typ = "gauge"
}
case mib.BaseTimeTicks:
dm.typ, dm.unit = "gauge", "seconds"
dm.comments = append(dm.comments, "TimeTicks: the poller converts to seconds")
case mib.BaseOctets:
dm.typ, dm.unit = "text", "text"
case mib.BaseOID, mib.BaseIPAddress, mib.BaseOpaque, mib.BaseBits:
dm.typ, dm.unit = "text", "text"
dm.comments = append(dm.comments, o.BaseType+" is collected as text")
default:
dm.typ = "gauge"
dm.comments = append(dm.comments, fmt.Sprintf("base type of %q could not be determined; check the type", o.TC))
}
}
var labelNameRE = regexp.MustCompile(`(?i)(descr|name|label|alias)`)
// labelFor picks the index_label for a table column: ifIndex-indexed tables are
// labelled by interface; otherwise the first DisplayString-like column of the table.
func labelFor(l *mib.Loader, m *mib.Module, o *mib.Object) (col, as, note string) {
if indexedByIfIndex(l, m, o) {
return "ifIndex", "iface", ""
}
if best := bestLabelColumn(m, o); best != nil {
return best.Name, "component", ""
}
return "TODO", "component", "no DisplayString column found in the table: set index_label.column"
}
// indexedByIfIndex follows AUGMENTS (up to 4 rows deep) looking for ifIndex in the row's INDEX.
func indexedByIfIndex(l *mib.Loader, m *mib.Module, o *mib.Object) bool {
row := rowOf(m, o)
for depth := 0; row != nil && depth < 4; depth++ {
if slices.Contains(row.Index, "ifIndex") {
return true
}
if row.Augments == "" {
break
}
next := m.Lookup(row.Augments) // AUGMENTS: the row shares the augmented row's index
if next == nil {
next, _ = l.Object(row.Augments)
}
row = next
}
return false
}
// bestLabelColumn is the lowest-OID readable string column of o's table that
// looks like a name (DisplayString, a text DISPLAY-HINT or a descr/name/label/alias name).
func bestLabelColumn(m *mib.Module, o *mib.Object) *mib.Object {
var best *mib.Object
for _, c := range m.Objects {
if c.Kind != mib.KindColumn || c.Table != o.Table || !readable(c) || c.BaseType != mib.BaseOctets {
continue
}
if c.TC != "DisplayString" && !strings.HasSuffix(c.DisplayHint, "a") && !labelNameRE.MatchString(c.Name) {
continue
}
if best == nil || oidLess(c.OID, best.OID) {
best = c
}
}
return best
}
func rowOf(m *mib.Module, col *mib.Object) *mib.Object {
i := strings.LastIndexByte(col.OID, '.')
if i < 0 {
return nil
}
for _, o := range m.Objects {
if o.Kind == mib.KindRow && o.OID == col.OID[:i] {
return o
}
}
return nil
}
func writeDraftMetric(b *strings.Builder, dm draftMetric) {
o := dm.obj
oid := o.OID
if o.Kind == mib.KindScalar {
oid += ".0"
}
for _, c := range dm.comments {
fmt.Fprintf(b, " # %s\n", c)
}
if o.Description != "" {
d := o.Description
if len(d) > 100 {
d = d[:100] + "..."
}
fmt.Fprintf(b, " # %s\n", d)
}
fmt.Fprintf(b, " - name: %s\n oid: %s\n type: %s\n unit: %s\n interval: TODO\n", o.Name, oid, dm.typ, dm.unit)
if dm.scale != 0 {
fmt.Fprintf(b, " scale: %s\n", strconv.FormatFloat(dm.scale, 'f', -1, 64))
}
if dm.label != "" {
fmt.Fprintf(b, " index_label: { column: %s, as: %s }\n", dm.label, dm.labelAs)
}
if len(dm.enum) > 0 {
b.WriteString(" enum:\n")
keys := make([]int, 0, len(dm.enum))
for k := range dm.enum {
keys = append(keys, k)
}
sort.Ints(keys)
for _, k := range keys {
fmt.Fprintf(b, " %d: %q\n", k, dm.enum[k])
}
}
}

97
cmd/nmon/profile_probe.go Normal file
View File

@@ -0,0 +1,97 @@
package main
import (
"context"
"errors"
"fmt"
"io"
"net/netip"
"time"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/profiletest"
"github.com/v10networks/nmon/internal/snmp"
)
// resolveCredential normalises --version (v1, v2c, v3 or bare; default by
// whether a v3 user was given) into cred.Version and checks the secrets that
// version needs.
func resolveCredential(version string, cred *snmp.Credential) error {
switch version {
case "":
version = "2c"
if cred.V3User != "" {
version = "3"
}
case "v1", "v2c", "v3":
version = version[1:]
}
cred.Version = version
switch {
case version != "1" && version != "2c" && version != "3":
return errors.New("--version must be v1, v2c or v3")
case version != "3" && cred.Community == "":
return fmt.Errorf("--community is required for SNMP v%s", version)
case version == "3" && cred.V3User == "":
return errors.New("--v3-user is required for SNMPv3")
}
return nil
}
// profileTest implements `nmon profile test`: discovery plus one poll of every
// interval group with the matching profile, printing each metric.
func profileTest(ctx context.Context, args []string, stdout io.Writer) error {
fs := profileFlagSet("test",
"profile test <ip> [--port N] [--version 1|2c|3] [--community C | --v3-user U ...] [--data-dir DIR]")
var (
port int
version string
cred snmp.Credential
dataDir string
timeout time.Duration
)
fs.IntVar(&port, "port", 161, "device UDP port")
fs.StringVar(&version, "version", "", "SNMP version: v1, v2c or v3 (default v3 when --v3-user is set, else v2c)")
fs.StringVar(&cred.Community, "community", "", "v1/v2c community")
fs.StringVar(&cred.V3User, "v3-user", "", "SNMPv3 user name")
fs.StringVar(&cred.V3AuthProto, "v3-auth-proto", "", "SNMPv3 auth protocol: MD5, SHA, SHA224, SHA256, SHA384, SHA512 (default SHA256)")
fs.StringVar(&cred.V3AuthPass, "v3-auth-pass", "", "SNMPv3 authentication passphrase")
fs.StringVar(&cred.V3PrivProto, "v3-priv-proto", "", "SNMPv3 privacy protocol: DES, AES, AES192, AES256 (default AES)")
fs.StringVar(&cred.V3PrivPass, "v3-priv-pass", "", "SNMPv3 privacy passphrase")
fs.StringVar(&cred.V3Context, "v3-context", "", "SNMPv3 context name")
fs.StringVar(&dataDir, "data-dir", "", "load operator profiles from <data-dir>/profiles over the shipped ones")
fs.DurationVar(&timeout, "timeout", 0, "per-request SNMP timeout (default 2s)")
pos, err := parseInterspersed(fs, args)
if err != nil {
return flagErr(err)
}
if len(pos) != 1 {
return fmt.Errorf("usage: nmon profile test <ip> [flags]")
}
ip, err := netip.ParseAddr(pos[0])
if err != nil {
return fmt.Errorf("bad address %q: %w", pos[0], err)
}
if err := resolveCredential(version, &cred); err != nil {
return err
}
dir := ""
if dataDir != "" {
dir = config.ProfilesDir(dataDir)
}
set, err := profile.Load(dir)
if err != nil {
return err
}
res, err := profiletest.Run(ctx, profiletest.Options{
Addr: netip.AddrPortFrom(ip, uint16(port)), Cred: cred, Profiles: set, Out: stdout, Timeout: timeout,
})
if err != nil {
return err
}
if res.Poll.Mismatches > 0 || res.Poll.WalkPDUs > 0 {
return fmt.Errorf("%d type mismatches, %d walk PDUs on the poll path", res.Poll.Mismatches, res.Poll.WalkPDUs)
}
return nil
}

179
cmd/nmon/profile_record.go Normal file
View File

@@ -0,0 +1,179 @@
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/netip"
"os"
"time"
"github.com/gosnmp/gosnmp"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/snmp"
)
// defaultRecordSubtrees is what `profile record` walks when --subtree is not given:
// system, interfaces, ifMIB, host, entity, entitySensor, UPS, printer and enterprises.
var defaultRecordSubtrees = []string{
"1.3.6.1.2.1.1", // system
"1.3.6.1.2.1.2", // interfaces
"1.3.6.1.2.1.31", // ifMIB
"1.3.6.1.2.1.25", // host
"1.3.6.1.2.1.47", // entity
"1.3.6.1.2.1.99", // entitySensor
"1.3.6.1.2.1.33", // upsMIB
"1.3.6.1.2.1.43", // printmib
"1.3.6.1.4.1", // enterprises
}
const redacted = "REDACTED"
var redactedOIDs = map[string]bool{
"1.3.6.1.2.1.1.4.0": true, // sysContact
"1.3.6.1.2.1.1.6.0": true, // sysLocation
}
type recordOpts struct {
Target snmp.Target
Subtrees []string
MaxReps int
MaxRecords int
}
func profileRecord(ctx context.Context, args []string, stdout io.Writer) error {
fs := profileFlagSet("record",
"profile record <ip> [--port N] [--community C | --version 3 --v3-user U ...] [--out FILE.snmprec] [--subtree OID]...")
var (
port int
version string
cred snmp.Credential
out string
subtrees listFlag
timeout time.Duration
maxReps int
maxRecords int
)
fs.IntVar(&port, "port", 161, "device UDP port")
fs.StringVar(&version, "version", "", "SNMP version: 1, 2c or 3 (default 3 when --v3-user is set, else 2c)")
fs.StringVar(&cred.Community, "community", "", "v1/v2c community")
fs.StringVar(&cred.V3User, "v3-user", "", "SNMPv3 user name")
fs.StringVar(&cred.V3AuthProto, "v3-auth-proto", "", "SNMPv3 auth protocol: MD5, SHA, SHA224, SHA256, SHA384, SHA512 (default SHA256)")
fs.StringVar(&cred.V3AuthPass, "v3-auth-pass", "", "SNMPv3 authentication passphrase")
fs.StringVar(&cred.V3PrivProto, "v3-priv-proto", "", "SNMPv3 privacy protocol: DES, AES, AES192, AES256, AES192C, AES256C (default AES)")
fs.StringVar(&cred.V3PrivPass, "v3-priv-pass", "", "SNMPv3 privacy passphrase")
fs.StringVar(&cred.V3Context, "v3-context", "", "SNMPv3 context name")
fs.StringVar(&out, "out", "", "write the recording here instead of stdout")
fs.Var(&subtrees, "subtree",
"OID to walk; repeatable (default: system, interfaces, ifMIB, host, entity, entitySensor, ups, printer, enterprises)")
fs.DurationVar(&timeout, "timeout", 3*time.Second, "per-request timeout")
fs.IntVar(&maxReps, "max-repetitions", 25, "GETBULK max-repetitions")
fs.IntVar(&maxRecords, "max-records", 500000, "stop with an error after this many varbinds")
pos, err := parseInterspersed(fs, args)
if err != nil {
return flagErr(err)
}
if len(pos) != 1 {
return fmt.Errorf("usage: nmon profile record <ip> [flags]")
}
ip, err := netip.ParseAddr(pos[0])
if err != nil {
return fmt.Errorf("bad address %q: %w", pos[0], err)
}
switch {
case version != "":
case cred.V3User != "":
version = "3"
default:
version = "2c"
}
cred.Version = version
if version != "3" && cred.Community == "" {
return fmt.Errorf("--community is required for SNMP v%s", version)
}
if version == "3" && cred.V3User == "" {
return fmt.Errorf("--v3-user is required for SNMPv3")
}
if len(subtrees) == 0 {
subtrees = defaultRecordSubtrees
}
var buf bytes.Buffer
n, err := recordDevice(ctx, recordOpts{
Target: snmp.Target{Addr: netip.AddrPortFrom(ip, uint16(port)), Cred: cred, Timeout: timeout},
Subtrees: subtrees,
MaxReps: maxReps,
MaxRecords: maxRecords,
}, &buf)
if err != nil {
return err
}
if out != "" {
if err := os.WriteFile(out, buf.Bytes(), 0o644); err != nil {
return err
}
fmt.Fprintf(os.Stderr, "recorded %d varbinds to %s\n", n, out)
return nil
}
_, err = stdout.Write(buf.Bytes())
return err
}
// recordDevice walks the subtrees read-only (GETBULK, or GETNEXT on v1) and writes
// an snmprec recording to w with the community, sysContact and sysLocation redacted.
// It returns the number of records written.
func recordDevice(ctx context.Context, o recordOpts, w io.Writer) (int, error) {
s, err := snmp.Dial(ctx, o.Target, "discovery")
if err != nil {
return 0, err
}
defer func() { _ = s.Close() }() // read-only session
var recs []simagent.Record
for _, root := range o.Subtrees {
err := s.Walk(ctx, root, o.MaxReps, func(vb snmp.Varbind) error {
if vb.Exception() {
return nil
}
if len(recs) >= o.MaxRecords {
return fmt.Errorf("more than %d varbinds; narrow the walk with --subtree", o.MaxRecords)
}
recs = append(recs, redact(vb, o.Target.Cred.Community))
return nil
})
if err != nil {
return 0, fmt.Errorf("walk %s: %w", root, err)
}
}
rec := simagent.NewRecording(recs)
if rec.Len() == 0 {
return 0, fmt.Errorf("the device returned nothing under %v", o.Subtrees)
}
if err := fixturegen.WriteSnmprec(w, rec); err != nil {
return 0, err
}
return rec.Len(), nil
}
// redact turns a varbind into a recording row, replacing secrets with REDACTED.
func redact(vb snmp.Varbind, community string) simagent.Record {
r := simagent.Record{OID: vb.OID, Type: vb.Type, Value: vb.Value}
if redactedOIDs[vb.OID] {
r.Type, r.Value = gosnmp.OctetString, []byte(redacted)
return r
}
if community != "" {
switch v := vb.Value.(type) {
case []byte:
if string(v) == community {
r.Value = []byte(redacted)
}
case string:
if vb.Type == gosnmp.OctetString && v == community {
r.Value = []byte(redacted)
}
}
}
return r
}

224
cmd/nmon/profile_test.go Normal file
View File

@@ -0,0 +1,224 @@
package main
import (
"bytes"
"context"
"net/netip"
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
)
func bundledMIBs(t *testing.T) string {
t.Helper()
d, _ := filepath.Abs(filepath.Join("..", "..", "mibs"))
if _, err := os.Stat(d); err != nil {
t.Skip("bundled MIBs not present")
}
return d
}
func fillTODOs(s string) string {
s = strings.ReplaceAll(s, "interval: TODO", "interval: 5m")
return strings.ReplaceAll(s, "unit: TODO", "unit: count")
}
func runNew(t *testing.T, args ...string) string {
t.Helper()
var out bytes.Buffer
if err := profileNew(context.Background(), args, &out); err != nil {
t.Fatal(err)
}
return out.String()
}
func TestProfileNewIFMIBTable(t *testing.T) {
d := runNew(t, "--mib", "IF-MIB", "--table", "ifXTable", "--mibs-dir", bundledMIBs(t))
if _, err := profile.Parse("draft-if-mib.yaml", []byte(d)); err == nil || !strings.Contains(err.Error(), "TODO") {
t.Fatalf("an unfilled draft must be rejected naming TODO, got %v", err)
}
p, err := profile.Parse("draft-if-mib.yaml", []byte(fillTODOs(d)))
if err != nil {
t.Fatalf("filled draft: %v\n%s", err, d)
}
byName := map[string]profile.Metric{}
for _, m := range p.Metrics {
byName[m.Name] = m
}
hc := byName["ifHCInOctets"]
if hc.OID != "1.3.6.1.2.1.31.1.1.1.6" || hc.Type != profile.Counter64 || hc.IndexLabel == nil || hc.IndexLabel.Column != "ifIndex" || hc.IndexLabel.As != "iface" {
t.Errorf("ifHCInOctets = %+v", hc)
}
if m := byName["ifLinkUpDownTrapEnable"]; m.Type != profile.Status || m.Enum[1] != "enabled" {
t.Errorf("ifLinkUpDownTrapEnable = %+v", m)
}
if m := byName["ifAlias"]; m.Type != profile.Text {
t.Errorf("ifAlias = %+v", m)
}
if !strings.Contains(d, "DISPLAY-HINT") {
t.Errorf("expected a DISPLAY-HINT comment:\n%s", d)
}
}
func TestProfileNewScalarsAndObjects(t *testing.T) {
d := runNew(t, "--mib", "SNMPv2-MIB", "--objects", "sysUpTime,sysName", "--mibs-dir", bundledMIBs(t))
p, err := profile.Parse("draft-snmpv2-mib.yaml", []byte(fillTODOs(d)))
if err != nil {
t.Fatalf("%v\n%s", err, d)
}
if len(p.Metrics) != 2 || p.Metrics[0].OID != "1.3.6.1.2.1.1.3.0" || p.Metrics[0].Unit != "seconds" {
t.Errorf("metrics = %+v", p.Metrics)
}
}
func TestProfileNewHintAndUnits(t *testing.T) {
d := runNew(t, "--mib", "TEST-MIB", "--mibs-dir", "../../internal/mib/testdata")
for _, want := range []string{"suggest scale: 0.1", "oid: 1.3.6.1.4.1.99999.1.1.0", "unit: celsius", "type: status", "2: \"on\"",
"index_label: { column: testName, as: component }"} {
if !strings.Contains(d, want) {
t.Errorf("missing %q in:\n%s", want, d)
}
}
if _, err := profile.Parse("draft-test-mib.yaml", []byte(fillTODOs(d))); err != nil {
t.Fatalf("%v\n%s", err, d)
}
}
func TestProfileNewErrors(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{
{},
{"--mib", "NO-SUCH-MIB", "--mibs-dir", "../../internal/mib/testdata"},
{"--mib", "TEST-MIB", "--table", "nope", "--mibs-dir", "../../internal/mib/testdata"},
{"--mib", "TEST-MIB", "--objects", "testTable", "--mibs-dir", "../../internal/mib/testdata"},
} {
if err := profileNew(context.Background(), args, &out); err == nil {
t.Errorf("%v: expected an error", args)
}
}
}
const recordSample = `1.3.6.1.2.1.1.1.0|4|Test device
1.3.6.1.2.1.1.2.0|6|1.3.6.1.4.1.8072.3.2.10
1.3.6.1.2.1.1.3.0|67|123456
1.3.6.1.2.1.1.4.0|4|admin@example.com
1.3.6.1.2.1.1.5.0|4|secret-comm
1.3.6.1.2.1.1.6.0|4|Rack 12, Building 3
1.3.6.1.2.1.2.1.0|2|1
1.3.6.1.2.1.2.2.1.1.1|2|1
1.3.6.1.2.1.2.2.1.10.1|65|42
1.3.6.1.2.1.31.1.1.1.6.1|70|18446744073709551615
1.3.6.1.4.1.9999.1.1.0|4|secret-comm
1.3.6.1.4.1.9999.1.2.0|4x|00ff
1.3.6.1.4.1.9999.1.3.0|64|10.1.2.3
`
func TestProfileRecordRedacts(t *testing.T) {
rec, err := simagent.ParseRecording(strings.NewReader(recordSample))
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
ag, err := simagent.Start(ctx, simagent.Config{Recording: rec, Community: "secret-comm"})
if err != nil {
t.Fatal(err)
}
defer func() { _ = ag.Close() }() // test cleanup
addr := ag.Addr()
for _, ver := range []string{"2c", "1"} {
var out bytes.Buffer
err := profileRecordTo(ctx, &out, addr, ver)
if err != nil {
t.Fatalf("v%s: %v", ver, err)
}
s := out.String()
for _, secret := range []string{"secret-comm", "admin@example.com", "Rack 12"} {
if strings.Contains(s, secret) {
t.Errorf("v%s: recording leaks %q:\n%s", ver, secret, s)
}
}
if n := strings.Count(s, "REDACTED"); n != 4 {
t.Errorf("v%s: want 4 REDACTED values, got %d:\n%s", ver, n, s)
}
got, err := simagent.ParseRecording(strings.NewReader(s))
if err != nil {
t.Fatalf("v%s: output does not parse: %v", ver, err)
}
want := rec.Len()
if ver == "1" {
want-- // Counter64 does not exist in SNMPv1
}
if got.Len() != want {
t.Errorf("v%s: %d records, want %d\n%s", ver, got.Len(), want, s)
}
}
}
func profileRecordTo(ctx context.Context, out *bytes.Buffer, addr netip.AddrPort, ver string) error {
return profileRecord(ctx, []string{addr.Addr().String(), "--port", itoa(int(addr.Port())), "--community", "secret-comm",
"--version", ver, "--timeout", "1s", "--subtree", "1.3.6.1.2.1", "--subtree", "1.3.6.1.4.1"}, out)
}
func itoa(n int) string { return strconv.Itoa(n) }
func TestProfileRecordArgs(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{{}, {"10.0.0.1"}, {"nonsense", "--community", "x"}, {"10.0.0.1", "--version", "3"}} {
if err := profileRecord(context.Background(), args, &out); err == nil {
t.Errorf("%v: expected an error", args)
}
}
}
func TestProfileTestArgs(t *testing.T) {
var out bytes.Buffer
for _, args := range [][]string{{}, {"10.0.0.1"}, {"nonsense", "--community", "x"}, {"10.0.0.1", "--version", "v3"}, {"10.0.0.1", "--version", "v9", "--community", "x"}} {
if err := profileTest(context.Background(), args, &out); err == nil {
t.Errorf("%v: expected an error", args)
}
}
}
func TestProfileTestPrintsMetrics(t *testing.T) {
rec, err := simagent.LoadRecording("../../testdata/snmprec/juniper-junos.snmprec")
if err != nil {
t.Fatal(err)
}
a, err := simagent.Start(context.Background(), simagent.Config{Recording: rec, Community: "lab"})
if err != nil {
t.Fatal(err)
}
defer func() { _ = a.Close() }() // test cleanup
var out bytes.Buffer
err = profileTest(context.Background(), []string{a.Addr().Addr().String(), "--port", itoa(int(a.Addr().Port())), "--community", "lab"}, &out)
if err != nil {
t.Fatalf("%v\n%s", err, out.String())
}
for _, want := range []string{"profile juniper-junos", "ifHCInOctets", "jnxOperatingCPU", "PDUs per cycle", "0 type mismatches"} {
if !strings.Contains(out.String(), want) {
t.Errorf("output lacks %q:\n%s", want, out.String())
}
}
}
// The LibreNMS importer is a separate offline tool (DEVIATIONS #9): nmon must not link it,
// and neither may nmon pull in the code that reads LibreNMS definitions.
func TestNmonDoesNotImportLibreNMS(t *testing.T) {
out, err := exec.Command("go", "list", "-deps", ".").CombinedOutput()
if err != nil {
t.Skipf("go list unavailable: %v\n%s", err, out)
}
for _, pkg := range strings.Fields(string(out)) {
if strings.Contains(pkg, "internal/librenms") || strings.Contains(pkg, "nmon-librenms-import") {
t.Errorf("cmd/nmon depends on %s", pkg)
}
}
}

View File

@@ -2,12 +2,14 @@ package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"github.com/v10networks/nmon/internal/app"
"github.com/v10networks/nmon/internal/cliout"
"github.com/v10networks/nmon/internal/config"
)
@@ -23,7 +25,9 @@ var hidden = map[string]bool{"poll-interval": true, "snmp-port": true}
func newFlagSet(name, usage string) *flag.FlagSet {
fs := flag.NewFlagSet(name, flag.ContinueOnError)
fs.Usage = func() {
fmt.Fprintf(fs.Output(), "usage: nmon %s\n\nflags:\n", usage)
// Usage text is best-effort; a failed write to the terminal has nowhere to go.
out := cliout.New(fs.Output())
out.Printf("usage: nmon %s\n\nflags:\n", usage)
fs.VisitAll(func(f *flag.Flag) {
if hidden[f.Name] {
return
@@ -32,7 +36,7 @@ func newFlagSet(name, usage string) *flag.FlagSet {
if f.DefValue != "" && f.DefValue != "false" && f.DefValue != "0" {
def = fmt.Sprintf(" (default %s)", f.DefValue)
}
fmt.Fprintf(fs.Output(), " --%s\n %s%s\n", f.Name, f.Usage, def)
out.Printf(" --%s\n %s%s\n", f.Name, f.Usage, def)
})
}
return fs
@@ -49,11 +53,15 @@ func serve(ctx context.Context, args []string) error {
var f config.Flags
fs := newFlagSet("serve", "serve --data-dir DIR --insecure-http [flags]")
storeFlags(fs, &f)
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile, "SNMP credential key file; created on first run; must be outside --data-dir")
fs.StringVar(&f.SecretKeyFile, "secret-key-file", app.DefaultSecretKeyFile,
"SNMP credential key file; created on first run; must be outside --data-dir")
fs.StringVar(&f.Listen, "listen", app.DefaultListen, "address for the API")
fs.BoolVar(&f.InsecureHTTP, "insecure-http", false, "serve plain HTTP with no authentication (required until TLS and auth arrive in M6)")
fs.StringVar(&f.VMHotURL, "vm-hot-url", "", "use this VictoriaMetrics instance as the hot store instead of the embedded one")
fs.StringVar(&f.VMLongURL, "vm-long-url", "", "use this VictoriaMetrics instance as the long store instead of the embedded one")
fs.StringVar(&f.TrapListen, "trap-listen", app.DefaultTrapListen,
"UDP address for SNMP traps; empty disables the receiver "+
"(port 162 needs CAP_NET_BIND_SERVICE; without it traps are off and polling continues)")
fs.IntVar(&f.MaxInflight, "max-inflight", 512, "most SNMP polls in flight at once")
fs.DurationVar(&f.PollInterval, "poll-interval", 0, "dev: SNMP poll interval (default 60s)")
fs.IntVar(&f.SNMPPort, "snmp-port", 0, "dev: UDP port of every device's agent (default 161)")
@@ -70,7 +78,7 @@ func serve(ctx context.Context, args []string) error {
// flagErr turns flag.ErrHelp into a clean exit.
func flagErr(err error) error {
if err == flag.ErrHelp {
if errors.Is(err, flag.ErrHelp) {
os.Exit(0)
}
return err

47
docs/MAINTAINABILITY.md Normal file
View File

@@ -0,0 +1,47 @@
# Maintainability standard
**This is a hard rule** (owner decision, 2026-10-22). Code that breaks it does not merge, and a milestone with findings is not tagged. The machine enforces it: `make lint` runs golangci-lint with `.golangci.yml` (Go) and ESLint (web). Every `scripts/gate-m*.sh` runs `make lint` first, and so does CI.
## Limits (production code)
| What | Limit | Enforced by |
|---|---|---|
| Function length | 80 lines, 50 statements | `funlen` |
| Cyclomatic complexity | 15 per function | `cyclop` |
| Cognitive complexity | 20 per function | `gocognit` |
| Nesting | `if` depth 4 | `nestif` |
| Duplicated blocks | 150 tokens | `dupl` |
| Line length | 140 characters | `lll` |
| File length | 600 lines (split by concern before that) | `scripts/lint-filesize.sh` |
| Unused code, dead fields | none | `unused` |
| Deprecated APIs | none | `staticcheck` |
| Unchecked errors | none | `errcheck`, `errorlint` |
| Magic HTTP status numbers | none (use `http.Status*`) | `usestdlibvars` |
Tests (`_test.go`) are exempt from the length and complexity limits, because table-driven tests are long by nature. They must still pass every other linter.
## Architecture rules (enforced by `depguard` and `forbidigo`)
- **SQL and the ORM live only in `internal/store`.** No other package imports `github.com/uptrace/bun` or `database/sql`.
- **Layering:** `cmd → app → {api, poller, discovery, traps, alert, notify, …} → {store, snmp, profile, metrics, clock, …}`.
- Only `cmd/*` imports `internal/app`.
- Only `internal/app` (and tests) imports `internal/api`.
- Collection paths do not import each other: `poller`, `discovery`, `traps`, `ingest`, `checks`, `rollup` and `alert` share code through a lower package such as `internal/snmpx`, not through each other.
- `cmd/nmon` never imports `internal/librenms` (the importer is a separate tool).
- **Time comes from `internal/clock`.** `time.Now`, `time.Sleep`, `time.After`, `time.Tick` and `time.NewTicker` are forbidden outside `internal/clock`, `cmd/` and tests.
- **No global mutable state** except the `selfmon.Default` registry and package-level caches that are documented as such.
## Habits the linters can't check (reviewers enforce these)
- One concern per file. Name files after what they hold (`matcher.go`, not `util.go`). No `util`, `helpers` or `common` packages.
- Split a long function into named steps that read like the spec (`collect → match → apply → audit`), not into `doPart1/doPart2`.
- Errors are wrapped with context (`fmt.Errorf("discovery %s: %w", slug, err)`) and handled once.
- Every exported identifier has a doc comment that says *why* or *what contract*, not a restatement of the name.
- Comments explain decisions, not mechanics. A decision that departs from the spec goes in `docs/DEVIATIONS.md`.
- Tests are named for the behaviour they lock in (`TestStackSwapOpensOneReviewAndNeverMerges`).
- `//nolint` is allowed only with the linter named and a reason (`//nolint:funlen // generated table`). `nolintlint` enforces this.
- New dependencies need a line in `docs/DEVIATIONS.md` row 3 saying why the standard library isn't enough.
## Web (`web/`)
ESLint with typescript-eslint (strict), `max-lines-per-function` 80, `complexity` 15, `max-depth` 4, `max-lines` 400 per file, no `any`, react-hooks rules. `TrafficGraph.tsx` is the only file that imports uPlot. API types are only generated, never hand-written.

View File

@@ -61,4 +61,25 @@ Adversarial: stack-member swap; RMA; GRE tunnel on Juniper; 32-bit wrap at 10G m
## Gate log
_(filled in by the supervisor when the gate passes)_
**Passed 2026-10-22**, run by the supervisor (Opus) on linux/amd64.
| Check | Result |
|---|---|
| `make lint` (golangci-lint v2.14.0 + file sizes + ESLint) | **0 findings** (the maintainability hard rule took effect during M2: about 450 findings cleared, 3 justified `nolint`s for long OpenAPI doc tags) |
| `go test -race ./...` and `-tags 'integration vmembed'` | all ok |
| `./scripts/test-pg.sh` | ok (Postgres 16) |
| Flaky-test proof | `go test -race -count=20 -run 'Concurrency\|Degraded\|Slow\|GlobalInflight' ./internal/poller/` ok |
| `make gate-m1` | PASS (lint included) |
| `make gate-m2` | PASS: 25 fixtures over IPv6 and IPv4 (13 profiles + behaviour fixtures), exact PDU budgets, 0 walk PDUs on the poll path; v1 (GETNEXT) and v3 authPriv via `nmon profile test` |
| Real devices (`NMON_LIBRENMS_DIR`) | 12 LibreNMS recordings: every one matched the right profile, 0 type mismatches |
**Notable decisions in M2**
- MIBs are a sidecar, not embedded (DEVIATIONS 6). The LibreNMS importer is a separate offline tool (DEVIATIONS 9).
- Identity: ambiguous matches open a review and freeze the candidates until an admin resolves it. Clean matches are audited only when something changed.
- Polling: backoff and degraded start after 3 consecutive failed cycles. Lower interval groups are skipped while the hot group fails.
- Text metrics are never polled; they are discovery label sources only.
- A production timing race in the poller (a timer registered after the clock moved woke a full interval late) was found by the flaky-test investigation and fixed (`timerUntil`).
**Bugs found by real-device testing and fixed:** apc-ups UPS-MIB label columns were not-accessible, and text metrics were polled as scalars. The fixture generator now refuses to emit not-accessible columns.
**Carried forward:** identity-review resolve/merge endpoint (M3 API); `profile` `max_varbinds` key (deferred; poller option exists); a facets endpoint for the UI (M3).

View File

@@ -34,3 +34,22 @@ API and UI: openapi.json generated with the TS client and the incompatible-diff
## Gate log
_(filled in by the supervisor when the gate passes)_
## API contract for M3 (written before the WPs so the UI and API are built in parallel)
All JSON. Timestamps are RFC 3339 UTC. Lists use `?page=1&per_page=100` (max 1,000) and return `{items: [...], page, per_page, total}`. Errors are RFC 9457 `application/problem+json`. Existing M1/M2 shapes (`InterfaceView`, `DeviceView`, the `SNMPBody` input) are extended, never renamed.
| Route | Response |
|---|---|
| `GET /api/v1/devices?site=&role=&kind=&q=&state=&page=&per_page=` | `items: DeviceSummary[]`, where `DeviceSummary{slug, name, kind, site, role, mgmt_ip, profile, reachability: up\|down\|warning\|unknown\|maintenance, poll_state: ok\|degraded\|backoff, last_seen, interfaces_total, interfaces_down, warnings[]}` |
| `GET /api/v1/devices/{slug}` | `DeviceView` (existing) plus `parents[{slug,name}]`, `groups[{id,name,kind}]`, `sys_descr`, `sys_object_id`, `hw_serial`, `uptime_seconds`, `reachability`, `poll_state`, `warnings[]`, `interfaces: InterfaceView[]` with added `unreliable`, `retired_at`, and live fields `in_bps`, `out_bps`, `in_errors_ps`, `out_errors_ps` (5 min rate, null when unknown or unreliable) |
| `PUT /api/v1/devices/{slug}` | body: `{name?, site?, role?, enabled?, parents?: [slug], snmp?: SNMPBody}`; returns `DeviceView` |
| `GET /api/v1/interfaces?q=&page=` | `items: InterfaceHit[]`, where `InterfaceHit{id, device{slug,name}, ifname, ifalias, ifdescr, oper_status, admin_status, speed_bps, in_bps, out_bps}` (q matches ifname and ifalias, case-insensitive) |
| `GET /api/v1/graph?iface=&from=&to=&step=` | `{iface{id,ifname,ifalias,speed_bps,device{slug,name}}, step_seconds, tier: raw\|1h, stitched_at?: ts, partial: [{from,to}], unreliable: bool, series: {in: [[unix_s, bps\|null]...], out: [...], in_max?: [...], out_max?: [...]}, p95?: {in_bps, out_bps, source: materialised\|live, coverage}}`. Both directions come in one call. `dir=` is optional and filters to one direction. An `unreliable` interface returns empty series and `unreliable_reason` (e.g. `counter_32bit_fast_link`). A stitched response also carries `tiers: [{from, to, tier, step_seconds}]`; the top-level `step_seconds` is the step of the newest tier. |
| `GET /api/v1/poller/health` | `{devices: [{slug, name, state, varbinds, interval_s, base_interval_s, last_cycle_s, missed_deadlines, consecutive_failures, last_error, next_cycle}], totals: {pdus_per_min, timeouts_per_min, deadline_misses_per_min, write_queue_depth, write_queue_capacity, devices_degraded}}`. Sortable client-side. |
| `GET /api/v1/identity-reviews?page=` and `POST /api/v1/identity-reviews/{id}/resolve` | review list with candidates and evidence; resolve body `{action: merge\|keep_both, keep?: iface_id}` |
| `GET /api/v1/query`, `GET /api/v1/query_range` | (existing) |
| `GET /api/v1/devices/facets` | `{sites: string[], roles: string[], kinds: string[]}`: distinct values over non-retired devices, for the filter dropdowns |
| `GET /api/v1/meta` | `{version, build, retention{hot,fiveminute,long} (Go duration strings, e.g. "720h0m0s"), now}` for the UI footer and the range picker |
**Graph tiers:** for a range ≤ hot retention, raw data from hot with step = max(60 s, range/1000). For longer ranges, hot raw data where it exists stitched with `nmon_if_rate_bps_avg_1h`/`max_1h` from long before that point; `stitched_at` is the join timestamp. Until M5's rollup exists, long has no data, so the response carries `partial` for the uncovered span. That is honest, not an error.

View File

@@ -4,6 +4,7 @@ The build follows the six gated milestones in [`docs/spec/spec.md`](../spec/spec
Each milestone has its own document here. It is written before any code for that milestone and
its **Gate log** is filled in when the gate passes. A milestone is done only when:
0. `make lint` is clean: maintainability is a hard rule ([`docs/MAINTAINABILITY.md`](../MAINTAINABILITY.md)), and every gate script runs lint first,
1. every acceptance line it closes (from [`build-detail.md`](../spec/build-detail.md#acceptance-criteria)) has a test that passes,
2. `make test` (`go test -race ./...`) and `make test-integration` pass,
3. its gate command (`make gate-mN`) passes when run by the supervisor, and
@@ -12,9 +13,9 @@ its **Gate log** is filled in when the gate passes. A milestone is done only whe
| # | Milestone | Gate | Doc | Status |
|---|---|---|---|---|
| M1 | Skeleton and fixtures | `nmon serve` polls the counters fixture and `/api/v1/query` returns rising ifHCInOctets | [M1](M1-skeleton-and-fixtures.md) | **passed**, tagged `m1` |
| M2 | Discovery and profiles | every fixture's expect.yaml passes, including v1 and v3, and the PDU budget holds | [M2](M2-discovery-and-profiles.md) | in progress (WP1 done) |
| M3 | Graphs and UI | a port's in/out graph renders at 1 h, 24 h and 30 d from the counters fixture | [M3](M3-graphs-and-ui.md) | planned |
| M4 | Alerting | stopping a parent fixture sends one notification naming it and its children | [M4](M4-alerting.md) | planned |
| M2 | Discovery and profiles | every fixture's expect.yaml passes, including v1 and v3, and the PDU budget holds | [M2](M2-discovery-and-profiles.md) | **passed**, tagged `m2` |
| M3 | Graphs and UI | a port's in/out graph renders at 1 h, 24 h and 30 d from the counters fixture | [M3](M3-graphs-and-ui.md) | in progress (web UI done on mock data) |
| M4 | Alerting | stopping a parent fixture sends one notification naming it and its children | [M4](M4-alerting.md) | in progress (WP2 rules and WP3 notify done) |
| M5 | Push ingest, rollup, checks | a replayed Proxmox sample shows under its node and a 2 y query hits the 1 h tier | [M5](M5-ingest-rollup-checks.md) | planned |
| M6 | Auth and ops | a viewer gets 403 on PUT and the 2,000-device loadtest cycle finishes under 40 s | [M6](M6-auth-and-ops.md) | planned |
| M7 | gNMI streaming | after v1; not built in this run | — | out of scope |
@@ -31,6 +32,8 @@ Departures from the spec are recorded in [`docs/DEVIATIONS.md`](../DEVIATIONS.md
## Rules every WP follows (from "Decisions that don't move")
- **Maintainability is a hard rule.** Follow [`docs/MAINTAINABILITY.md`](../MAINTAINABILITY.md): zero golangci-lint and ESLint findings in your packages, the function, complexity and file-size limits, and the layering rules. Every WP brief cites it.
- One static binary, `CGO_ENABLED=0`. No raw SQL outside `internal/store`.
- The 60 s poll path talks only to devices (SNMP, ICMP) and the local hot store.
- Metric labels on SNMP series: `device=<slug>`, plus `iface=<ULID>` on interface series. Nothing else.
@@ -39,3 +42,11 @@ Departures from the spec are recorded in [`docs/DEVIATIONS.md`](../DEVIATIONS.md
- IPv6 first: every network path works on v6 and v4; tests default to `::1`.
- Time comes from `internal/clock`. No `time.Now()`/`time.Sleep` in logic that a test must control.
- Tests never need real hardware. Fixtures are `.snmprec` files served by `internal/simagent`.
## Deferred (revisit after v1 milestones)
- **Deployment playbook**: install layout (binary, `/usr/share/nmon/mibs` sidecar, systemd unit granting port 162, key file location), TLS/ACME or proxy setup, IdP configuration, backups, and upgrades. Raised by the user on 2026-10-22; deliberately postponed.
## Review artifacts
- From M3 on, each UI milestone's gate captures Playwright screenshots of every page into `docs/screenshots/<milestone>/` for the user to review.

Binary file not shown.

After

Width:  |  Height:  |  Size: 136 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 581 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 153 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 184 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

92
internal/alert/deliver.go Normal file
View File

@@ -0,0 +1,92 @@
package alert
import (
"context"
"time"
"github.com/v10networks/nmon/internal/store"
)
// deliver sends the row's pending notification if it is still valid and not
// suppressed. Suppressed notifications are held and sent when the suppression
// ends, if still valid; acked rows drop everything except a resolution.
func (ev *Evaluator) deliver(ctx context.Context, now time.Time, a *store.Alert, ent Entity) {
if ev.held(ctx, a, ent) {
return
}
p := a.NotifyPending
if p == "" {
return
}
acked := a.AckUntil != nil && now.Before(*a.AckUntil)
if !pendingValid(a, p) || (acked && p != NotifyResolved) {
a.NotifyPending = ""
ev.save(ctx, a)
return
}
if err := ev.o.Notifier.Notify(ctx, transitionOf(a, p)); err != nil {
ev.reg.Counter("nmon_alert_notify_errors_total", "rule", a.Rule).Inc()
ev.log.Warn("notify failed, will retry", "rule", a.Rule, "key", a.SeriesKey, "state", p, "err", err)
return
}
a.NotifyPending = ""
if p != NotifyStale {
a.NotifiedState = p
}
ev.save(ctx, a)
}
// held records the suppression reason on an active row and reports whether the
// pending notification must wait for the suppression to end.
func (ev *Evaluator) held(ctx context.Context, a *store.Alert, ent Entity) bool {
sup, why := ev.suppressed(ent)
if !isActive(a.State) {
return sup && a.NotifyPending != ""
}
reason := ""
if sup {
reason = why
if reason == "" {
reason = "suppressed"
}
}
if reason != a.SuppressedBy {
a.SuppressedBy = reason
ev.save(ctx, a)
}
return reason != "" && a.NotifyPending != ""
}
// pendingValid reports whether notification p still describes the row.
func pendingValid(a *store.Alert, p string) bool {
switch p {
case NotifyFiring:
return a.BaseState == store.AlertFiring && a.State != store.AlertFlapping
case NotifyResolved:
return a.State == store.AlertResolved && (a.NotifiedState == NotifyFiring || a.NotifiedState == NotifyFlapping)
case NotifyStale:
return a.State == store.AlertStale && a.PriorState == store.AlertFiring && a.NotifiedState == NotifyFiring
case NotifyFlapping:
return a.State == store.AlertFlapping
}
return false
}
func transitionOf(a *store.Alert, state string) Transition {
t := Transition{
AlertID: a.ID, Rule: a.Rule, State: state, Severity: a.Severity, Device: a.DeviceSlug, Iface: a.Iface,
Component: a.Component, Value: a.Value, Threshold: a.Threshold, ClearedAt: a.ClearedAt,
Summary: a.Summary, Description: a.Description,
}
if a.FiredAt != nil {
t.FiredAt = *a.FiredAt
}
return t
}
func (ev *Evaluator) suppressed(ent Entity) (bool, string) {
if ev.o.Suppressed == nil {
return false, ""
}
return ev.o.Suppressed(ent)
}

265
internal/alert/evaluator.go Normal file
View File

@@ -0,0 +1,265 @@
package alert
import (
"context"
"errors"
"fmt"
"log/slog"
"sort"
"sync"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
)
// Timing of the state machine.
const (
// DefaultInterval is the evaluation period.
DefaultInterval = 30 * time.Second
// StaleAfter is how many consecutive evaluations without a series make it stale.
StaleAfter = 2
// FlapWindow and FlapMax: more than FlapMax state changes within
// FlapWindow put the entity into flapping for FlapHold.
FlapWindow = 10 * time.Minute
FlapMax = 5
FlapHold = 15 * time.Minute
queryTimeout = 25 * time.Second
// StoreHot is the name of the store rules run against.
StoreHot = "hot"
)
// Notification states handed to the Notifier.
const (
NotifyFiring = "firing"
NotifyResolved = "resolved"
NotifyStale = "stale"
NotifyFlapping = "flapping"
)
// Entity identifies one alerting series: a rule on a device, optionally an
// interface or a component of it.
type Entity struct {
Rule, Device, Iface, Component string
}
// Key is the persisted series key.
func (e Entity) Key() string { return e.Rule + "|" + e.Device + "|" + e.Iface + "|" + e.Component }
// Transition is one notification the evaluator wants delivered. The incident ID
// is "<AlertID>-<FiredAt unix>" (notify.IncidentID); a new firing after a
// resolution has a new AlertID.
type Transition struct {
AlertID int64
Rule, State, Severity string
Device, Iface, Component string
Value, Threshold float64
FiredAt time.Time
ClearedAt *time.Time
Summary, Description string
}
// Notifier receives transitions. A non-nil error means "not delivered"; the
// evaluator retries on its next cycle. The app adapts it to notify.Queue.
type Notifier interface {
Notify(ctx context.Context, t Transition) error
}
// Querier is the part of vmsup.Querier the evaluator uses.
type Querier interface {
Query(ctx context.Context, store, q string, ts time.Time) ([]byte, error)
}
// Options configures an Evaluator. Store, Querier, Notifier, Clock and Rules
// are required.
type Options struct {
Store store.Alerts
Querier Querier
Notifier Notifier
Clock clock.Clock
Rules []Rule
// Groups answers group membership for Rule.Overrides. Optional.
Groups GroupLookup
// ThresholdFor supplies a per-device (per-profile) threshold for a rule;
// ok false falls through to the rule default. Group overrides beat it.
ThresholdFor func(device, rule string) (threshold float64, ok bool)
// Unreliable reports an interface whose counters cannot be trusted
// (32-bit counters on fast links); rules with SkipUnreliable ignore it.
Unreliable func(iface string) bool
// Active reports whether an entity still exists (device and interface not
// retired). Inactive entities are closed as retired, silently.
Active func(Entity) bool
// Suppressed reports a maintenance window or dependency suppression for
// an entity. A suppressed alert still changes state but is not notified.
Suppressed func(Entity) (bool, string)
// StoreHealth is told after every query whether the store answered.
StoreHealth func(ok bool, err error)
Interval time.Duration // default DefaultInterval
StoreName string // default StoreHot
Metrics *selfmon.Registry
Logger *slog.Logger
}
// Evaluator runs the rules and owns the persisted state machine.
type Evaluator struct {
o Options
rules []*compiledRule
log *slog.Logger
reg *selfmon.Registry
mu sync.Mutex
}
// New validates the rules and returns an Evaluator.
func New(o Options) (*Evaluator, error) {
if o.Store == nil || o.Querier == nil || o.Notifier == nil || o.Clock == nil {
return nil, errors.New("alert: Store, Querier, Notifier and Clock are required")
}
if o.Interval <= 0 {
o.Interval = DefaultInterval
}
if o.StoreName == "" {
o.StoreName = StoreHot
}
if o.Logger == nil {
o.Logger = slog.Default()
}
if o.Metrics == nil {
o.Metrics = selfmon.Default
}
ev := &Evaluator{o: o, log: o.Logger.With("component", "alert"), reg: o.Metrics}
seen := map[string]bool{}
for _, r := range o.Rules {
if seen[r.Name] {
return nil, fmt.Errorf("alert: duplicate rule %q", r.Name)
}
seen[r.Name] = true
if r.Disabled {
continue
}
c, err := compile(r)
if err != nil {
return nil, err
}
ev.rules = append(ev.rules, c)
ev.reg.Counter("nmon_alert_eval_errors_total", "rule", r.Name) // visible at 0
}
return ev, nil
}
// Run evaluates once now and then every Interval until ctx ends.
func (ev *Evaluator) Run(ctx context.Context) error {
t := ev.o.Clock.NewTicker(ev.o.Interval)
defer t.Stop()
for {
ev.EvalOnce(ctx)
select {
case <-ctx.Done():
return ctx.Err()
case <-t.C():
}
}
}
type reading struct {
ent Entity
value float64
}
// EvalOnce runs one evaluation cycle at the clock's current time.
func (ev *Evaluator) EvalOnce(ctx context.Context) {
ev.mu.Lock()
defer ev.mu.Unlock()
now := ev.o.Clock.Now().UTC()
rows, err := ev.o.Store.ListActiveAlertStates(ctx)
if err != nil {
ev.log.Error("load alert state", "err", err)
return // without state nothing can be decided; do not touch anything
}
active := ev.sweepRows(ctx, now, rows)
for _, r := range ev.rules {
ev.evalRule(ctx, now, r, active[r.Name])
}
}
// sweepRows settles rows that need no query (closed rows still owing a
// notification, rows of vanished entities or rules) and groups the rest by rule.
func (ev *Evaluator) sweepRows(ctx context.Context, now time.Time, rows []store.Alert) map[string][]*store.Alert {
ruleByName := map[string]*compiledRule{}
for _, r := range ev.rules {
ruleByName[r.Name] = r
}
active := map[string][]*store.Alert{} // rule -> active rows
for i := range rows {
a := &rows[i]
ent := entityOf(a)
if !isActive(a.State) {
ev.deliver(ctx, now, a, ent) // closed row with an undelivered notification
continue
}
if ruleByName[a.Rule] == nil || (ev.o.Active != nil && !ev.o.Active(ent)) {
ev.retire(ctx, now, a)
continue
}
active[a.Rule] = append(active[a.Rule], a)
}
return active
}
// evalRule queries one rule and applies each reading to its entity's row;
// rows whose series vanished get the no-data treatment.
func (ev *Evaluator) evalRule(ctx context.Context, now time.Time, r *compiledRule, rows []*store.Alert) {
readings, qerr := ev.query(ctx, r, now)
if ev.o.StoreHealth != nil {
ev.o.StoreHealth(qerr == nil, qerr)
}
if qerr != nil {
// State untouched: not below, not no-data, and never a resolution.
ev.reg.Counter("nmon_alert_eval_errors_total", "rule", r.Name).Inc()
ev.log.Warn("rule evaluation failed", "rule", r.Name, "err", qerr)
return
}
byKey := map[string]*store.Alert{}
for _, a := range rows {
byKey[a.SeriesKey] = a
}
seen := map[string]bool{}
for _, rd := range readings {
key := rd.ent.Key()
seen[key] = true
thr := r.resolveThreshold(rd.ent.Device, ev.o.Groups, ev.o.ThresholdFor)
ev.apply(ctx, now, r, rd, thr, byKey[key])
}
for _, a := range rows {
if !seen[a.SeriesKey] {
ev.nodata(ctx, now, r, a)
}
}
}
func isActive(state string) bool {
switch state {
case store.AlertPending, store.AlertFiring, store.AlertStale, store.AlertFlapping:
return true
}
return false
}
func entityOf(a *store.Alert) Entity {
return Entity{Rule: a.Rule, Device: a.DeviceSlug, Iface: a.Iface, Component: a.Component}
}
// SortedRuleNames lists the enabled rules, for diagnostics.
func (ev *Evaluator) SortedRuleNames() []string {
var n []string
for _, r := range ev.rules {
n = append(n, r.Name)
}
sort.Strings(n)
return n
}

View File

@@ -0,0 +1,660 @@
package alert
import (
"context"
"encoding/json"
"errors"
"fmt"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
)
type fakeQ struct {
mu sync.Mutex
series map[string][]string // rule expr -> "device|component|value"
err map[string]error
calls int
}
func (q *fakeQ) Query(_ context.Context, _ string, expr string, _ time.Time) ([]byte, error) {
q.mu.Lock()
defer q.mu.Unlock()
q.calls++
if err := q.err[expr]; err != nil {
return nil, err
}
var res []map[string]any
for _, s := range q.series[expr] {
p := strings.Split(s, "|")
m := map[string]string{"device": p[0]}
if p[1] != "" {
m["component"] = p[1]
}
res = append(res, map[string]any{"metric": m, "value": []any{1, p[2]}})
}
return json.Marshal(map[string]any{"status": "success", "data": map[string]any{"resultType": "vector", "result": res}})
}
func (q *fakeQ) set(expr string, series ...string) {
q.mu.Lock()
defer q.mu.Unlock()
q.series[expr] = series
q.err = map[string]error{}
}
type fakeN struct {
mu sync.Mutex
got []Transition
fail error
}
func (n *fakeN) Notify(_ context.Context, t Transition) error {
n.mu.Lock()
defer n.mu.Unlock()
if n.fail != nil {
return n.fail
}
n.got = append(n.got, t)
return nil
}
func (n *fakeN) states() string {
n.mu.Lock()
defer n.mu.Unlock()
var s []string
for _, t := range n.got {
s = append(s, t.State)
}
return strings.Join(s, ",")
}
func (n *fakeN) reset() { n.mu.Lock(); n.got = nil; n.mu.Unlock() }
const testExpr = "test_expr"
type harness struct {
t *testing.T
st store.Store
clk *clock.Fake
q *fakeQ
n *fakeN
reg *selfmon.Registry
opts Options
ev *Evaluator
}
func testRule(f time.Duration) Rule {
return Rule{Name: "disk_full", Severity: SevCritical, Expr: testExpr, Op: OpGreater, Threshold: 90, For: f,
Labels: []string{"device", "component"}, Summary: "{{.Device}} {{.Component}} {{.Value}}", Description: "d"}
}
func newHarness(t *testing.T, rules ...Rule) *harness {
t.Helper()
if len(rules) == 0 {
rules = []Rule{testRule(0)}
}
dir := t.TempDir()
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
ctx := context.Background()
st, err := store.Open(ctx, store.DriverSQLite, filepath.Join(dir, "inv.db"), nil, clk)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = st.Close() })
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
h := &harness{t: t, st: st, clk: clk, q: &fakeQ{series: map[string][]string{}, err: map[string]error{}}, n: &fakeN{}, reg: selfmon.New()}
h.opts = Options{Store: st, Querier: h.q, Notifier: h.n, Clock: clk, Rules: rules, Metrics: h.reg}
h.restart()
return h
}
func (h *harness) restart() {
ev, err := New(h.opts)
if err != nil {
h.t.Fatal(err)
}
h.ev = ev
}
func (h *harness) step(d time.Duration) {
h.clk.Advance(d)
h.ev.EvalOnce(context.Background())
}
func (h *harness) alerts() []store.Alert {
as, _, err := h.st.ListAlerts(context.Background(), store.AlertFilter{}, store.Page{Limit: 1000})
if err != nil {
h.t.Fatal(err)
}
return as
}
func (h *harness) only() store.Alert {
h.t.Helper()
as := h.alerts()
if len(as) != 1 {
h.t.Fatalf("want 1 alert row, got %d", len(as))
}
return as[0]
}
func (h *harness) want(notifs, state string) {
h.t.Helper()
if got := h.n.states(); got != notifs {
h.t.Fatalf("notifications = %q, want %q", got, notifs)
}
if state != "" {
if got := h.alerts()[0].State; got != state { // newest row
h.t.Fatalf("state = %q, want %q", got, state)
}
}
}
const hi, lo = "d1|/var|95", "d1|/var|50"
func TestFiringAndResolveOnlyOnBelow(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(30 * time.Second)
h.want("firing", "firing")
h.step(30 * time.Second)
h.want("firing", "firing") // not re-notified
h.q.set(testExpr) // series gone: no-data, not below
h.step(30 * time.Second)
h.step(30 * time.Second)
h.want("firing,stale", "stale")
h.q.set(testExpr, lo)
h.step(30 * time.Second)
h.want("firing,stale,resolved", "resolved")
a := h.only()
if a.ClearedAt == nil || a.FiredAt == nil {
t.Fatal("fired/cleared not recorded")
}
}
func TestForDuration(t *testing.T) {
h := newHarness(t, testRule(2*time.Minute))
h.q.set(testExpr, hi)
h.step(30 * time.Second)
h.want("", "pending")
h.step(time.Minute)
h.want("", "pending")
h.step(time.Minute) // 2 min since pending
h.want("firing", "firing")
// A pending that drops below never fires and leaves no row.
h2 := newHarness(t, testRule(2*time.Minute))
h2.q.set(testExpr, hi)
h2.step(30 * time.Second)
h2.q.set(testExpr, lo)
h2.step(30 * time.Second)
h2.want("", "")
if n := len(h2.alerts()); n != 0 {
t.Fatalf("pending row kept: %d", n)
}
}
func TestStaleAfterTwoNoDataThenStillFiringOnReading(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(time.Second)
h.q.set(testExpr)
h.step(30 * time.Second)
h.want("firing", "firing") // one miss is nothing
h.step(30 * time.Second)
h.want("firing,stale", "stale")
h.step(30 * time.Second)
h.want("firing,stale", "stale") // exactly once
// The row remembers it was firing, and keeps the incident.
a := h.only()
if a.PriorState != store.AlertFiring || a.FiredAt == nil {
t.Fatalf("prior=%q fired=%v", a.PriorState, a.FiredAt)
}
h.q.set(testExpr, hi)
h.step(30 * time.Second)
h.want("firing,stale", "firing") // back to firing without a new notification
if h.only().NodataCount != 0 {
t.Fatal("nodata not reset")
}
}
func TestQueryErrorsLeaveFiringAndCount(t *testing.T) {
h := newHarness(t)
var health []bool
h.opts.StoreHealth = func(ok bool, _ error) { health = append(health, ok) }
h.restart()
h.q.set(testExpr, hi)
h.step(time.Second)
id := h.only().ID
h.q.err[testExpr] = errors.New("store down")
for i := 0; i < 10; i++ { // far past two evaluations
h.step(30 * time.Second)
}
a := h.only()
if a.State != store.AlertFiring || a.NodataCount != 0 || a.ID != id {
t.Fatalf("state %q nodata %d", a.State, a.NodataCount)
}
h.want("firing", "firing")
if got := h.reg.Counter("nmon_alert_eval_errors_total", "rule", "disk_full").Value(); got != 10 {
t.Fatalf("errors = %v", got)
}
if health[len(health)-1] || !health[0] {
t.Fatalf("health = %v", health)
}
// Recovers and keeps firing without a notification.
h.q.err = map[string]error{}
h.step(30 * time.Second)
h.want("firing", "firing")
}
func TestBadResponseIsAnError(t *testing.T) {
for _, raw := range []string{`{"status":"error","error":"boom"}`, `not json`, `{"status":"success","data":{"resultType":"matrix","result":[]}}`} {
if _, err := parseVector([]byte(raw)); err == nil {
t.Errorf("%s: want error", raw)
}
}
v, err := parseVector([]byte(`{"status":"success","data":{"resultType":"vector","result":[{"metric":{"device":"a"},"value":[1,"NaN"]},{"metric":{"device":"b"},"value":[1,"+Inf"]}]}}`))
if err != nil || len(v) != 1 || v[0].labels["device"] != "b" {
t.Fatalf("%v %v", v, err)
}
}
func TestRetireClosesSilently(t *testing.T) {
h := newHarness(t)
active := true
h.opts.Active = func(e Entity) bool { return active || e.Device != "d1" }
h.restart()
h.q.set(testExpr, hi)
h.step(time.Second)
h.q.set(testExpr)
h.step(30 * time.Second)
h.step(30 * time.Second)
h.want("firing,stale", "stale")
active = false
h.step(30 * time.Second)
h.want("firing,stale", "retired") // no "resolved"
if a := h.only(); a.ClearedAt == nil {
t.Fatal("retired row not closed")
}
// Series still in the store for a retired device are ignored.
h.q.set(testExpr, hi)
h.step(30 * time.Second)
h.want("firing,stale", "retired")
// A firing (not stale) alert retires the same way.
active = true
h.step(30 * time.Second)
h.n.reset()
active = false
h.q.set(testExpr)
h.step(30 * time.Second)
}
func TestRestartDoesNotRenotifyOrForget(t *testing.T) {
h := newHarness(t, testRule(time.Minute))
h.q.set(testExpr, hi)
h.step(time.Second)
h.restart() // pending survives
h.step(time.Minute)
h.want("firing", "firing")
h.restart()
h.step(30 * time.Second)
h.restart()
h.step(30 * time.Second)
h.want("firing", "firing")
if n := len(h.alerts()); n != 1 {
t.Fatalf("%d rows", n)
}
// And a restart does not resolve either, though the series is missing.
h.q.set(testExpr)
h.restart()
h.step(30 * time.Second)
h.want("firing", "firing")
}
func TestNewIncidentAfterResolveGetsNewID(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(time.Second)
first := h.n.got[0]
h.q.set(testExpr, lo)
h.step(time.Minute)
h.q.set(testExpr, hi)
h.step(time.Minute)
h.want("firing,resolved,firing", "")
second := h.n.got[2]
if first.AlertID == second.AlertID || first.FiredAt.Equal(second.FiredAt) {
t.Fatalf("incident reused: %+v %+v", first, second)
}
if h.n.got[1].AlertID != first.AlertID || h.n.got[1].ClearedAt == nil {
t.Fatal("resolved should carry the first incident")
}
if n := len(h.alerts()); n != 2 {
t.Fatalf("history rows = %d", n)
}
}
func TestFlapping(t *testing.T) {
h := newHarness(t)
// Six changes: firing, resolved, firing, resolved, firing, resolved.
for i := 0; i < 6; i++ {
if i%2 == 0 {
h.q.set(testExpr, hi)
} else {
h.q.set(testExpr, lo)
}
h.step(time.Minute)
}
// The sixth change (a resolution) became the one flapping notification.
h.want("firing,resolved,firing,resolved,firing,flapping", "flapping")
h.n.reset()
// Further flipping is silent.
for i := 0; i < 9; i++ {
if i%2 == 0 {
h.q.set(testExpr, hi)
} else {
h.q.set(testExpr, lo)
}
h.step(time.Minute)
}
h.want("", "flapping")
h.q.set(testExpr, hi)
h.step(6 * time.Minute) // 15 min after entry: exits with the then-current state
h.want("firing", "firing")
h.step(time.Minute)
h.want("firing", "firing")
if n := len(h.alerts()); n != 3 {
t.Fatalf("rows = %d (3 incidents: the flapping one hosts the rest)", n)
}
}
func TestFlappingExitResolved(t *testing.T) {
h := newHarness(t)
for i := 0; i < 6; i++ {
h.q.set(testExpr, []string{hi, lo}[i%2])
h.step(time.Minute)
}
h.n.reset()
h.q.set(testExpr, lo)
h.step(16 * time.Minute)
h.want("resolved", "")
for _, a := range h.alerts() {
if a.State == store.AlertFlapping {
t.Fatal("still flapping")
}
}
h.step(time.Minute)
h.want("resolved", "")
}
func TestFlapPersistsAcrossRestart(t *testing.T) {
h := newHarness(t)
for i := 0; i < 6; i++ {
h.q.set(testExpr, []string{hi, lo}[i%2])
h.step(time.Minute)
if i == 4 {
h.restart()
}
}
h.want("firing,resolved,firing,resolved,firing,flapping", "flapping")
h.restart()
h.q.set(testExpr, hi)
h.step(time.Minute)
h.n.reset()
h.q.set(testExpr, lo)
h.step(time.Minute)
h.want("", "flapping")
}
func TestMaintenanceSuppressesNotificationNotState(t *testing.T) {
h := newHarness(t)
sup := true
h.opts.Suppressed = func(Entity) (bool, string) { return sup, "maintenance" }
h.restart()
h.q.set(testExpr, hi)
h.step(time.Second)
h.want("", "firing")
if h.only().SuppressedBy != "maintenance" {
t.Fatal("reason not recorded")
}
h.step(time.Minute)
h.want("", "firing")
// Fired and resolved wholly inside the window: never announced.
h.q.set(testExpr, lo)
h.step(time.Minute)
sup = false
h.step(time.Minute)
h.want("", "resolved")
// Still firing when the window ends: announced then.
h2 := newHarness(t)
s2 := true
h2.opts.Suppressed = func(Entity) (bool, string) { return s2, "maintenance" }
h2.restart()
h2.q.set(testExpr, hi)
h2.step(time.Second)
s2 = false
h2.step(time.Minute)
h2.want("firing", "firing")
if h2.only().SuppressedBy != "" {
t.Fatal("reason not cleared")
}
}
func TestAckSuppressesUntilExpiry(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(time.Second)
id := h.only().ID
ctx := context.Background()
if err := h.st.AckAlert(ctx, id, "ann", "known", h.clk.Now().Add(10*time.Minute)); err != nil {
t.Fatal(err)
}
// Goes stale and comes back while acked: stale is not announced.
h.q.set(testExpr)
h.step(30 * time.Second)
h.step(30 * time.Second)
h.want("firing", "stale")
h.q.set(testExpr, lo)
h.step(30 * time.Second)
// A resolution is still announced.
h.want("firing,resolved", "resolved")
// Unacked, an equivalent stale does notify; an expired ack does not suppress.
h2 := newHarness(t)
h2.q.set(testExpr, hi)
h2.step(time.Second)
if err := h2.st.AckAlert(ctx, h2.only().ID, "ann", "x", h2.clk.Now().Add(time.Minute)); err != nil {
t.Fatal(err)
}
h2.q.set(testExpr)
h2.step(2 * time.Minute)
h2.step(30 * time.Second)
h2.want("firing,stale", "stale")
a := h2.only()
if a.AckBy != "ann" || a.AckReason != "x" || a.AckUntil == nil {
t.Fatalf("ack lost on update: %+v", a)
}
if err := h2.st.AckAlert(ctx, a.ID+99, "a", "b", h2.clk.Now().Add(time.Hour)); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("got %v", err)
}
}
func TestNotifyFailureRetries(t *testing.T) {
h := newHarness(t)
h.n.fail = errors.New("queue full")
h.q.set(testExpr, hi)
h.step(time.Second)
h.want("", "firing")
h.step(30 * time.Second)
h.n.fail = nil
h.step(30 * time.Second)
h.want("firing", "firing")
h.step(30 * time.Second)
h.want("firing", "firing")
}
func TestGroupOverrideMostSpecificWins(t *testing.T) {
r := testRule(0)
r.Overrides = []Override{{Group: "all", Threshold: 80}, {Group: "dc1", Threshold: 95}, {Group: "db", Threshold: 99}}
g := fakeGroups{"d1": {{"all", 100}, {"dc1", 10}}, "d2": {{"all", 100}}, "d3": {{"db", 2}, {"dc1", 10}}}
if got := r.resolveThreshold("d1", g, nil); got != 95 {
t.Errorf("d1 = %v", got)
}
if got := r.resolveThreshold("d2", g, nil); got != 80 {
t.Errorf("d2 = %v", got)
}
if got := r.resolveThreshold("d3", g, nil); got != 99 {
t.Errorf("d3 = %v", got)
}
if got := r.resolveThreshold("d4", g, func(d, rule string) (float64, bool) { return 77, true }); got != 77 {
t.Errorf("d4 = %v", got)
}
if got := r.resolveThreshold("d1", g, func(d, rule string) (float64, bool) { return 77, true }); got != 95 {
t.Errorf("group beats profile: %v", got)
}
if got := r.resolveThreshold("d4", g, nil); got != 90 {
t.Errorf("default = %v", got)
}
h := newHarness(t, r)
h.opts.Groups = g
h.restart()
h.q.set(testExpr, "d1|/|92", "d2|/|92")
h.step(time.Second)
if h.n.states() != "firing" || h.n.got[0].Device != "d2" {
t.Fatalf("only d2 (threshold 80) should fire: %+v", h.n.got)
}
}
type fakeGroups map[string][]GroupRef
func (f fakeGroups) GroupsOf(d string) []GroupRef { return f[d] }
func TestUnreliableInterfaceSkipped(t *testing.T) {
r := Rule{Name: "interface_errors", Severity: SevWarning, Expr: testExpr, Op: OpGreater, Threshold: 0.1,
Labels: []string{"device", "component"}, SkipUnreliable: true, Summary: "s", Description: "d"}
h := newHarness(t, r)
h.opts.Unreliable = func(i string) bool { return i == "bad" }
h.restart()
h.q.set(testExpr, "d1|bad|5", "d1|good|5")
// The fake puts the third field in "component", Iface is empty, so use device label instead.
h2 := newHarness(t, func() Rule { r.Labels = []string{"device"}; return r }())
h2.opts.Unreliable = func(string) bool { return true }
h2.restart()
h2.q.set(testExpr, "d1||5")
h2.step(time.Second)
h2.want("firing", "") // no iface label: filter has nothing to match
}
func TestRetiredRuleClosesRows(t *testing.T) {
h := newHarness(t)
h.q.set(testExpr, hi)
h.step(time.Second)
h.opts.Rules = nil
h.restart()
h.step(time.Second)
if got := h.only().State; got != store.AlertRetired {
t.Fatalf("state %q", got)
}
h.want("firing", "")
}
func TestPruneAndList(t *testing.T) {
h := newHarness(t)
ctx := context.Background()
h.q.set(testExpr, hi, "d2|/|95")
h.step(time.Second)
h.q.set(testExpr, lo, "d2|/|95")
h.step(time.Minute)
all, n, err := h.st.ListAlerts(ctx, store.AlertFilter{}, store.Page{Limit: 1})
if err != nil || n != 2 || len(all) != 1 {
t.Fatalf("%v %d %d", err, n, len(all))
}
act, n, _ := h.st.ListAlerts(ctx, store.AlertFilter{Active: true}, store.Page{})
if n != 1 || act[0].DeviceSlug != "d2" {
t.Fatalf("active %+v", act)
}
res, n, _ := h.st.ListAlerts(ctx, store.AlertFilter{States: []string{store.AlertResolved}, Device: "d1"}, store.Page{})
if n != 1 || len(res) != 1 {
t.Fatal("resolved filter")
}
if pruned, err := h.st.PruneAlerts(ctx, h.clk.Now().Add(-time.Hour)); err != nil || pruned != 0 {
t.Fatalf("%d %v", pruned, err)
}
h.clk.Advance(400 * 24 * time.Hour)
if pruned, err := h.st.PruneAlerts(ctx, h.clk.Now().Add(-365*24*time.Hour)); err != nil || pruned != 1 {
t.Fatalf("%d %v", pruned, err)
}
if _, n, _ := h.st.ListAlerts(ctx, store.AlertFilter{}, store.Page{}); n != 1 {
t.Fatalf("active row pruned: %d", n)
}
}
func TestDefaultRulesValid(t *testing.T) {
seen := map[string]bool{}
for _, r := range DefaultRules() {
if err := r.Validate(); err != nil {
t.Errorf("%v", err)
}
if _, err := compile(r); err != nil {
t.Errorf("%v", err)
}
seen[r.Name] = true
// Every default renders with real data.
c, _ := compile(r)
out := c.render(c.summary, TemplateData{Rule: r.Name, Device: "d", Iface: "i", Component: "c", Value: 1.5, Threshold: 2})
if strings.Contains(out, "template error") || out == "" {
t.Errorf("%s summary %q", r.Name, out)
}
}
for _, n := range []string{"interface_down", "interface_errors", "cpu_high", "memory_high", "disk_full", "ups_on_battery", "ups_runtime_low", "printer_supply_low", "temp_high"} {
if !seen[n] {
t.Errorf("missing default %s", n)
}
}
for _, bad := range []string{"", "up{", "sum(x))", `x{a="b}`} {
if checkExprShape(bad) == nil {
t.Errorf("%q passed shape check", bad)
}
}
r := DefaultRules()[0]
r.Op = "="
if r.Validate() == nil {
t.Error("bad op accepted")
}
}
func TestMergeRules(t *testing.T) {
got, err := MergeRules(DefaultRules(), []Rule{
{Name: "cpu_high", Threshold: 95, For: 5 * time.Minute, Overrides: []Override{{Group: "g", Threshold: 99}}},
{Name: "temp_high", Disabled: true},
{Name: "custom", Severity: SevInfo, Expr: "up == 0", Op: OpLess, Threshold: 1, Labels: []string{"device"}, Summary: "x", Description: "y"},
})
if err != nil {
t.Fatal(err)
}
byName := map[string]Rule{}
for _, r := range got {
byName[r.Name] = r
}
c := byName["cpu_high"]
if c.Threshold != 95 || c.For != 5*time.Minute || c.Expr == "" || c.Severity != SevWarning || len(c.Overrides) != 1 {
t.Errorf("cpu_high %+v", c)
}
if !byName["temp_high"].Disabled || byName["custom"].Expr == "" {
t.Error("merge")
}
if _, err := MergeRules(nil, []Rule{{Name: "x"}}); err == nil {
t.Error("incomplete rule accepted")
}
_ = fmt.Sprint
}

View File

@@ -0,0 +1,150 @@
//go:build integration && linux
package alert
import (
"context"
"os"
"path/filepath"
"runtime"
"testing"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/metrics"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/vmsup"
)
// TestIntegrationDefaultRulesAgainstRealStore runs every default rule through
// the real hot store with synthetic samples: each query must be accepted, and
// the ones with data must fire.
func TestIntegrationDefaultRulesAgainstRealStore(t *testing.T) {
bin, err := filepath.Abs(filepath.Join("..", "vmsup", "bin", "victoria-metrics-prod-linux-"+runtime.GOARCH))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(bin); err != nil {
t.Skipf("store binary not found (%v); run scripts/fetch-vm.sh", err)
}
t.Setenv("NMON_VM_BINARY", bin)
t.Setenv("NMON_VM_BINARY_UNVERIFIED", "")
sup, err := vmsup.New(vmsup.Options{DataDir: t.TempDir()})
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
defer cancel()
if err := sup.Start(ctx); err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sup.Stop(context.Background()) })
// Samples every minute for 7 h, ending 2 min ago (the store hides the
// newest seconds from instant queries).
end := time.Now().Add(-2 * time.Minute).Truncate(time.Minute)
var ss []metrics.Sample
for i := 0; i <= 7*60; i++ {
ts := end.Add(-time.Duration(7*60-i) * time.Minute)
frac := float64(i) / (7 * 60)
add := func(name string, v float64, kv ...string) { ss = append(ss, metrics.New(name, v, ts, kv...)) }
add("hrProcessorLoad", 96, "device", "cpu1", "component", "1")
add("hrProcessorLoad", 94, "device", "cpu1", "component", "2")
// /data grows 100 -> 800 of 1000: 80% now, but full within 24 h at that slope.
add("hrStorageUsed", 100+700*frac, "device", "disk1", "component", "/data")
add("hrStorageSize", 1000, "device", "disk1", "component", "/data")
// / is steady and fine; "Physical memory" is full but is not a disk.
add("hrStorageUsed", 400, "device", "disk1", "component", "/")
add("hrStorageSize", 1000, "device", "disk1", "component", "/")
add("hrStorageUsed", 970, "device", "mem1", "component", "Physical memory")
add("hrStorageSize", 1000, "device", "mem1", "component", "Physical memory")
add("ifOperStatus", 2, "device", "sw1", "iface", "if1")
add("ifAdminStatus", 1, "device", "sw1", "iface", "if1")
add("ifOperStatus", 2, "device", "sw1", "iface", "if2") // admin down: not an alert
add("ifAdminStatus", 2, "device", "sw1", "iface", "if2")
add("upsOutputSource", 5, "device", "ups1")
add("upsAdvBatteryRunTimeRemaining", 300, "device", "ups1")
add("prtMarkerSuppliesLevel", 5, "device", "prn1", "component", "Black Toner")
add("prtMarkerSuppliesMaxCapacity", 100, "device", "prn1", "component", "Black Toner")
add("prtMarkerSuppliesLevel", -2, "device", "prn1", "component", "Cyan Toner")
add("prtMarkerSuppliesMaxCapacity", 100, "device", "prn1", "component", "Cyan Toner")
add("mtxrHlTemperature", 80, "device", "rb1")
add("ifInErrors", float64(i)*60, "device", "sw1", "iface", "if3")
add("ifOutErrors", 0, "device", "sw1", "iface", "if3")
add("ifHCInOctets", float64(i)*60*150000, "device", "sw1", "iface", "if3") // 2500 pkt/s at 1500 B... errors 1/s
add("ifHCOutOctets", 0, "device", "sw1", "iface", "if3")
add("memAvailReal", 50, "device", "lin1")
add("memTotalReal", 1000, "device", "lin1")
}
q := vmsup.NewQueue(vmsup.StoreHot, sup, vmsup.QueueOptions{MaxAge: 72 * time.Hour})
if err := q.Write(ctx, ss); err != nil {
t.Fatal(err)
}
if err := q.Flush(ctx); err != nil {
t.Fatal(err)
}
db := filepath.Join(t.TempDir(), "inv.db")
clk := clock.NewFake(time.Now())
st, err := store.Open(ctx, store.DriverSQLite, db, nil, clk)
if err != nil {
t.Fatal(err)
}
defer func() { _ = st.Close() }()
if err := st.Migrate(ctx); err != nil {
t.Fatal(err)
}
rules := DefaultRules()
for i := range rules {
rules[i].For = 0
}
n := &fakeN{}
reg := selfmon.New()
ev, err := New(Options{Store: st, Querier: vmsup.NewQuerier(sup), Notifier: n, Clock: clk, Rules: rules, Metrics: reg})
if err != nil {
t.Fatal(err)
}
// The store ingests asynchronously; retry until the data is searchable.
deadline := time.Now().Add(40 * time.Second)
for {
ev.EvalOnce(ctx)
if len(n.got) >= 10 || time.Now().After(deadline) {
break
}
time.Sleep(2 * time.Second)
clk.Advance(2 * time.Second)
}
for _, r := range rules {
if e := reg.Counter("nmon_alert_eval_errors_total", "rule", r.Name).Value(); e != 0 {
t.Errorf("rule %s: %v query errors", r.Name, e)
}
}
fired := map[string]bool{}
for _, tr := range n.got {
if tr.State == NotifyFiring {
fired[tr.Rule+"/"+tr.Device+"/"+tr.Iface+"/"+tr.Component] = true
}
}
for _, want := range []string{
"cpu_high/cpu1//",
"disk_full/disk1///data", // predicted full only
"memory_high/mem1//",
"memory_high/lin1//", // UCD
"interface_down/sw1/if1/",
"ups_on_battery/ups1//",
"ups_runtime_low/ups1//",
"printer_supply_low/prn1//Black Toner",
"temp_high/rb1//mtxrHlTemperature",
"interface_errors/sw1/if3/",
} {
if !fired[want] {
t.Errorf("did not fire: %s (fired: %v)", want, fired)
}
}
for _, not := range []string{"disk_full/disk1///", "disk_full/mem1//Physical memory", "interface_down/sw1/if2/", "printer_supply_low/prn1//Cyan Toner"} {
if fired[not] {
t.Errorf("fired but should not: %s", not)
}
}
}

128
internal/alert/query.go Normal file
View File

@@ -0,0 +1,128 @@
package alert
import (
"context"
"encoding/json"
"fmt"
"math"
"strconv"
"time"
)
// query runs a rule and returns one reading per active, trusted entity.
func (ev *Evaluator) query(ctx context.Context, r *compiledRule, now time.Time) ([]reading, error) {
qctx, cancel := context.WithTimeout(ctx, queryTimeout)
defer cancel()
raw, err := ev.o.Querier.Query(qctx, ev.o.StoreName, r.Expr, now)
if err != nil {
return nil, err
}
series, err := parseVector(raw)
if err != nil {
return nil, err
}
best := map[string]reading{}
var order []string
for _, s := range series {
ent, ok := ev.entityOfSeries(r, s)
if !ok {
continue
}
k := ent.Key()
cur, seen := best[k]
if !seen {
order = append(order, k)
}
// Duplicate series for one entity: keep the one nearer to alerting.
if !seen || r.nearer(s.value, cur.value) {
best[k] = reading{ent, s.value}
}
}
out := make([]reading, 0, len(order))
for _, k := range order {
out = append(out, best[k])
}
return out, nil
}
// nearer reports whether a is closer to breaching than b.
func (r *compiledRule) nearer(a, b float64) bool {
if r.Op == OpLess {
return a < b
}
return a > b
}
// entityOfSeries maps a series to its entity. It reports false for series that
// lack a label the rule needs, are on unreliable interfaces, or are inactive.
func (ev *Evaluator) entityOfSeries(r *compiledRule, s sample) (Entity, bool) {
ent := Entity{Rule: r.Name}
for _, l := range r.Labels {
v := s.labels[l]
if v == "" {
ev.log.Debug("series without label", "rule", r.Name, "label", l)
return Entity{}, false
}
switch l {
case "device":
ent.Device = v
case "iface":
ent.Iface = v
case "component":
ent.Component = v
}
}
if r.SkipUnreliable && ent.Iface != "" && ev.o.Unreliable != nil && ev.o.Unreliable(ent.Iface) {
return Entity{}, false
}
if ev.o.Active != nil && !ev.o.Active(ent) {
return Entity{}, false
}
return ent, true
}
type sample struct {
labels map[string]string
value float64
}
// parseVector reads an instant-query response. NaN samples are dropped (no data).
func parseVector(raw []byte) ([]sample, error) {
var resp struct {
Status string `json:"status"`
ErrorType string `json:"errorType"`
Error string `json:"error"`
Data struct {
ResultType string `json:"resultType"`
Result []struct {
Metric map[string]string `json:"metric"`
Value [2]json.RawMessage `json:"value"`
} `json:"result"`
} `json:"data"`
}
if err := json.Unmarshal(raw, &resp); err != nil {
return nil, fmt.Errorf("alert: bad query response: %w", err)
}
if resp.Status != "success" {
return nil, fmt.Errorf("alert: query status %q: %s %s", resp.Status, resp.ErrorType, resp.Error)
}
if resp.Data.ResultType != "vector" {
return nil, fmt.Errorf("alert: query returned %q, want vector", resp.Data.ResultType)
}
var out []sample
for _, r := range resp.Data.Result {
var str string
if err := json.Unmarshal(r.Value[1], &str); err != nil {
return nil, fmt.Errorf("alert: bad sample value %s", r.Value[1])
}
v, err := strconv.ParseFloat(str, 64)
if err != nil {
return nil, fmt.Errorf("alert: bad sample value %q", str)
}
if math.IsNaN(v) {
continue
}
out = append(out, sample{r.Metric, v})
}
return out, nil
}

426
internal/alert/rules.go Normal file
View File

@@ -0,0 +1,426 @@
// Package alert evaluates metric alert rules against the hot store every 30 s
// and keeps a persisted per-series state machine (pending, firing, resolved,
// stale, flapping). Reachability alerts are not here; they never need the
// metric store.
package alert
import (
"bytes"
"errors"
"fmt"
"strings"
"text/template"
"time"
)
// Severities.
const (
SevCritical = "critical"
SevWarning = "warning"
SevInfo = "info"
)
// Comparison operators.
const (
OpGreater = ">"
OpLess = "<"
)
// Rule is one metric alert rule. Expr is an instant MetricsQL query that
// returns one series per entity whose value is the measured quantity; the
// evaluator compares it with the threshold (so thresholds can differ per group
// or per device without rewriting the query). A series that is absent from a
// result is "no data", never "below".
type Rule struct {
Name string `yaml:"name" json:"name"`
Severity string `yaml:"severity" json:"severity"`
Expr string `yaml:"expr" json:"expr"`
Op string `yaml:"op" json:"op"` // ">" or "<"
// Threshold is the default; see Overrides and Evaluator Options.ThresholdFor.
Threshold float64 `yaml:"threshold" json:"threshold"`
For time.Duration `yaml:"for" json:"for"`
// Labels names the series labels that identify the entity, any of device,
// iface, component. A series missing one of them is skipped.
Labels []string `yaml:"labels" json:"labels"`
// Summary and Description are text/template strings over TemplateData.
Summary string `yaml:"summary" json:"summary"`
Description string `yaml:"description" json:"description"`
// Overrides are per-group thresholds; the most specific group wins.
Overrides []Override `yaml:"overrides" json:"overrides"`
// SkipUnreliable drops series whose interface the Unreliable filter flags
// (32-bit counters on fast links wrap too quickly to trust).
SkipUnreliable bool `yaml:"skip_unreliable" json:"skip_unreliable"`
Disabled bool `yaml:"disabled" json:"disabled"`
}
// Override replaces a rule's threshold for devices in a group.
type Override struct {
Group string `yaml:"group" json:"group"`
Threshold float64 `yaml:"threshold" json:"threshold"`
}
// TemplateData is what Summary and Description templates see.
type TemplateData struct {
Rule, Severity, Device, Iface, Component string
Value, Threshold float64
Op string
For time.Duration
}
var entityLabels = map[string]bool{"device": true, "iface": true, "component": true}
// Validate checks a rule is usable: names, operator, label set, parseable
// templates and a structurally sound expression.
func (r Rule) Validate() error {
var errs []error
add := func(f string, a ...any) {
errs = append(errs, fmt.Errorf("rule %q: "+f, append([]any{r.Name}, a...)...))
}
if strings.TrimSpace(r.Name) == "" || strings.ContainsAny(r.Name, "| \t\n") {
add("name must be non-empty without spaces or '|'")
}
switch r.Severity {
case SevCritical, SevWarning, SevInfo:
default:
add("severity %q is not critical, warning or info", r.Severity)
}
if r.Op != OpGreater && r.Op != OpLess {
add("op %q is not > or <", r.Op)
}
if r.For < 0 {
add("for is negative")
}
r.validateLabels(add)
if err := checkExprShape(r.Expr); err != nil {
add("expr: %v", err)
}
r.validateOverridesAndTemplates(add)
return errors.Join(errs...)
}
// validateLabels checks the rule's label set names only entity labels.
func (r Rule) validateLabels(add func(string, ...any)) {
if len(r.Labels) == 0 {
add("labels is empty")
}
for _, l := range r.Labels {
if !entityLabels[l] {
add("label %q is not device, iface or component", l)
}
}
}
// validateOverridesAndTemplates checks overrides name a group and the
// summary and description templates parse.
func (r Rule) validateOverridesAndTemplates(add func(string, ...any)) {
for _, o := range r.Overrides {
if o.Group == "" {
add("override without a group")
}
}
for n, t := range map[string]string{"summary": r.Summary, "description": r.Description} {
if _, err := template.New(n).Option("missingkey=error").Parse(t); err != nil {
add("%s template: %v", n, err)
}
}
}
// checkExprShape is a cheap structural check (non-empty, balanced brackets and
// quotes). The real parser is the store; the integration test runs every
// default rule against it.
func checkExprShape(s string) error {
if strings.TrimSpace(s) == "" {
return errors.New("empty")
}
var stack []byte
var quote byte
for i := 0; i < len(s); i++ {
c := s[i]
if quote != 0 {
switch {
case c == '\\' && quote != '`':
i++
case c == quote:
quote = 0
}
continue
}
switch c {
case '"', '\'', '`':
quote = c
case '(', '[', '{':
stack = append(stack, c)
case ')', ']', '}':
want := map[byte]byte{')': '(', ']': '[', '}': '{'}[c]
if len(stack) == 0 || stack[len(stack)-1] != want {
return fmt.Errorf("unbalanced %q at offset %d", c, i)
}
stack = stack[:len(stack)-1]
}
}
if quote != 0 {
return errors.New("unterminated string")
}
if len(stack) != 0 {
return fmt.Errorf("unclosed %q", stack[len(stack)-1])
}
return nil
}
type compiledRule struct {
Rule
summary, desc *template.Template
}
func compile(r Rule) (*compiledRule, error) {
if err := r.Validate(); err != nil {
return nil, err
}
c := &compiledRule{Rule: r}
var err error
if c.summary, err = template.New("summary").Option("missingkey=error").Parse(r.Summary); err != nil {
return nil, err
}
if c.desc, err = template.New("description").Option("missingkey=error").Parse(r.Description); err != nil {
return nil, err
}
return c, nil
}
func (c *compiledRule) render(t *template.Template, d TemplateData) string {
var b bytes.Buffer
if err := t.Execute(&b, d); err != nil {
return t.Name() + " template error: " + err.Error()
}
return b.String()
}
// breach reports whether v is on the alerting side of threshold.
func (r Rule) breach(v, threshold float64) bool {
if r.Op == OpLess {
return v < threshold
}
return v > threshold
}
// GroupRef is a group a device belongs to. Size is the member count; a smaller
// group is more specific.
type GroupRef struct {
Name string
Size int
}
// GroupLookup answers group membership for threshold overrides.
type GroupLookup interface {
GroupsOf(device string) []GroupRef
}
// resolveThreshold picks the threshold for a device: the override of the most
// specific (smallest) group the device is in, ties going to the earlier
// override in the rule; else thresholdFor (per-profile) if it has one; else the
// rule default.
func (r Rule) resolveThreshold(device string, g GroupLookup, thresholdFor func(device, rule string) (float64, bool)) float64 {
if g != nil && len(r.Overrides) > 0 {
size := map[string]int{}
for _, gr := range g.GroupsOf(device) {
size[gr.Name] = gr.Size
}
best, bestSize, found := 0.0, 0, false
for _, o := range r.Overrides {
sz, ok := size[o.Group]
if ok && (!found || sz < bestSize) {
best, bestSize, found = o.Threshold, sz, true
}
}
if found {
return best
}
}
if thresholdFor != nil {
if v, ok := thresholdFor(device, r.Name); ok {
return v
}
}
return r.Threshold
}
// patchRule overlays the non-zero tunables of c onto d.
func patchRule(d, c Rule) Rule {
if c.Severity != "" {
d.Severity = c.Severity
}
if c.Op != "" {
d.Op = c.Op
}
if c.Threshold != 0 {
d.Threshold = c.Threshold
}
if c.For != 0 {
d.For = c.For
}
if c.Overrides != nil {
d.Overrides = c.Overrides
}
d.Disabled = c.Disabled
return d
}
// MergeRules applies configured rules over defaults. A configured rule with
// the name of a default and no Expr patches that default's severity, op,
// threshold, for, overrides and disabled flag (zero values keep the default;
// set Disabled to switch a rule off). A rule with an Expr replaces the default
// wholesale; an unknown name needs a full definition. Order: defaults first,
// then new rules in config order.
func MergeRules(defaults, configured []Rule) ([]Rule, error) {
out := append([]Rule(nil), defaults...)
idx := map[string]int{}
for i, r := range out {
idx[r.Name] = i
}
for _, c := range configured {
i, known := idx[c.Name]
switch {
case known && c.Expr == "":
out[i] = patchRule(out[i], c)
case known:
out[i] = c
default:
idx[c.Name] = len(out)
out = append(out, c)
}
}
for _, r := range out {
if err := r.Validate(); err != nil {
return nil, err
}
}
return out, nil
}
const (
ramRE = `(?i).*(physical memory|(^|[^a-z])ram([^a-z]|$)).*`
notDiskE = `(?i).*(memory|swap|buffers|cached|(^|[^a-z])ram([^a-z]|$)).*`
)
// DefaultRules returns the shipped rules. Notes on the less obvious queries:
//
// - Every gauge is wrapped in last_over_time over a window of at least three
// poll intervals, so a 5 min poll does not fall out of the store's default
// 5 min lookback and read as "no data".
// - interface_errors: error ratio in percent. Packets are rate of
// ifHCIn/OutUcastPkts when the device exports them, else
// (ifHCInOctets+ifHCOutOctets)/1500 (the shipped profiles carry no packet
// counters, so the approximation is the usual path and understates packets
// for small-packet traffic, which only makes the alert earlier). The
// denominator is floored at 10 pkt/s so an idle port with a single error
// does not read as 100% (and a zero denominator never yields NaN).
// - memory_high: UCD memAvailReal/memTotalReal when present, else the
// hrStorage rows whose component (hrStorageDescr) matches /physical memory|ram/i.
// hrStorageType is not a label, so the description is all there is. UCD
// "available" excludes cache, so it reads high on Linux; that is the
// conventional reading.
// - disk_full: every hrStorage row that is not memory, swap, buffers or cache.
// The value is max(used%, 100 if predict_linear over 6 h reaches the size
// within 24 h; tagged kind=predicted so union keeps both), so one threshold covers both conditions; a threshold of 100
// or more disables the prediction half in effect.
// - printer_supply_low: negative levels (-1 other, -2 unknown, -3 "some
// remaining") are filtered out; the series then goes stale rather than low.
// - temp_high: the temperature metrics of the shipped profiles, each tagged
// with its metric name as component where the profile has no component
// label. entPhySensorValue is excluded, since it mixes units. Per-profile
// thresholds arrive through Options.ThresholdFor; the default is 70.
func DefaultRules() []Rule {
lab := func(l ...string) []string { return l }
return append(hostRules(lab), deviceRules(lab)...)
}
// hostRules are the interface, CPU, memory and disk rules of DefaultRules.
func hostRules(lab func(...string) []string) []Rule {
return []Rule{
{
Name: "interface_down", Severity: SevWarning, Op: OpGreater, Threshold: 0, For: 2 * time.Minute,
Labels: lab("device", "iface"),
Expr: `(ifOperStatus == bool 2) * on(device, iface) (ifAdminStatus == bool 1)`,
Summary: `{{.Device}} interface {{.Iface}} is down`,
Description: `Interface {{.Iface}} on {{.Device}} is operationally down while administratively up (for {{.For}}).`,
},
{
Name: "interface_errors", Severity: SevWarning, Op: OpGreater, Threshold: 0.1, For: 0,
Labels: lab("device", "iface"), SkipUnreliable: true,
Expr: `100 * (rate(ifInErrors[5m]) + rate(ifOutErrors[5m])) / clamp_min(` +
`(rate(ifHCInUcastPkts[5m]) + rate(ifHCOutUcastPkts[5m])) or ` +
`((rate(ifHCInOctets[5m]) + rate(ifHCOutOctets[5m])) / 1500), 10)`,
Summary: `{{.Device}} interface {{.Iface}} error ratio {{printf "%.2f" .Value}}%`,
Description: `Errors are {{printf "%.2f" .Value}}% of packets over 5 minutes on {{.Iface}} of {{.Device}} ` +
`(threshold {{printf "%.2f" .Threshold}}%).`,
},
{
Name: "cpu_high", Severity: SevWarning, Op: OpGreater, Threshold: 90, For: 10 * time.Minute,
Labels: lab("device"),
Expr: `avg by (device) (last_over_time(hrProcessorLoad[15m]))`,
Summary: `{{.Device}} CPU at {{printf "%.0f" .Value}}%`,
Description: `Average processor load on {{.Device}} is {{printf "%.1f" .Value}}% (threshold {{printf "%.0f" .Threshold}}%).`,
},
{
Name: "memory_high", Severity: SevWarning, Op: OpGreater, Threshold: 90, For: 10 * time.Minute,
Labels: lab("device"),
Expr: `(100 * (1 - last_over_time(memAvailReal[15m]) / (last_over_time(memTotalReal[15m]) > 0))) or on(device) ` +
`(max by (device) (100 * last_over_time(hrStorageUsed{component=~"` + ramRE + `"}[15m]) / ` +
`(last_over_time(hrStorageSize{component=~"` + ramRE + `"}[15m]) > 0)))`,
Summary: `{{.Device}} memory at {{printf "%.0f" .Value}}%`,
Description: `Memory use on {{.Device}} is {{printf "%.1f" .Value}}% (threshold {{printf "%.0f" .Threshold}}%).`,
},
{
Name: "disk_full", Severity: SevCritical, Op: OpGreater, Threshold: 90, For: 0,
Labels: lab("device", "component"),
Expr: `max by (device, component) (union(` +
`100 * last_over_time(hrStorageUsed{component!~"` + notDiskE + `"}[15m]) / ` +
`(last_over_time(hrStorageSize{component!~"` + notDiskE + `"}[15m]) > 0), ` +
`label_set(100 * (predict_linear(hrStorageUsed{component!~"` + notDiskE + `"}[6h], 86400) > bool ` +
`last_over_time(hrStorageSize{component!~"` + notDiskE + `"}[15m])), "kind", "predicted")))`,
Summary: `{{.Device}} {{.Component}} is {{printf "%.0f" .Value}}% full or filling`,
Description: `Storage {{.Component}} on {{.Device}} is above {{printf "%.0f" .Threshold}}% used or ` +
`predicted to fill within 24 hours (value {{printf "%.1f" .Value}}).`,
},
}
}
// deviceRules are the UPS, printer and temperature rules of DefaultRules.
func deviceRules(lab func(...string) []string) []Rule {
return []Rule{
{
Name: "ups_on_battery", Severity: SevCritical, Op: OpGreater, Threshold: 0, For: 0,
Labels: lab("device"),
Expr: `max by (device) (last_over_time(upsOutputSource[15m]) == bool 5)`,
Summary: `{{.Device}} UPS is on battery`,
Description: `The UPS {{.Device}} reports its output source as battery.`,
},
{
Name: "ups_runtime_low", Severity: SevCritical, Op: OpLess, Threshold: 10, For: time.Minute,
Labels: lab("device"),
Expr: `min by (device) (last_over_time(upsEstimatedMinutesRemaining[15m])) or on(device) ` +
`min by (device) (last_over_time(upsAdvBatteryRunTimeRemaining[15m]) / 60)`,
Summary: `{{.Device}} UPS runtime {{printf "%.0f" .Value}} min`,
Description: `The UPS {{.Device}} estimates {{printf "%.1f" .Value}} minutes of runtime (threshold {{printf "%.0f" .Threshold}}).`,
},
{
Name: "printer_supply_low", Severity: SevWarning, Op: OpLess, Threshold: 10, For: 0,
Labels: lab("device", "component"),
Expr: `100 * (last_over_time(prtMarkerSuppliesLevel[1h]) >= 0) / ` +
`(last_over_time(prtMarkerSuppliesMaxCapacity[1h]) > 0)`,
Summary: `{{.Device}} {{.Component}} at {{printf "%.0f" .Value}}%`,
Description: `Supply {{.Component}} on {{.Device}} is at {{printf "%.0f" .Value}}% (threshold {{printf "%.0f" .Threshold}}%).`,
},
{
Name: "temp_high", Severity: SevWarning, Op: OpGreater, Threshold: 70, For: 5 * time.Minute,
Labels: lab("device", "component"),
Expr: `union(` +
`label_set(last_over_time(mtxrHlTemperature[15m]), "component", "mtxrHlTemperature"), ` +
`label_set(last_over_time(mtxrHlProcessorTemperature[15m]), "component", "mtxrHlProcessorTemperature"), ` +
`label_set(last_over_time(upsAdvBatteryTemperature[15m]), "component", "upsAdvBatteryTemperature"), ` +
`last_over_time(jnxOperatingTemp[15m]))`,
Summary: `{{.Device}} {{.Component}} at {{printf "%.0f" .Value}} C`,
Description: `Temperature {{.Component}} on {{.Device}} is {{printf "%.1f" .Value}} C (threshold {{printf "%.0f" .Threshold}}).`,
},
}
}

241
internal/alert/state.go Normal file
View File

@@ -0,0 +1,241 @@
package alert
import (
"context"
"encoding/json"
"time"
"github.com/v10networks/nmon/internal/store"
)
func (ev *Evaluator) save(ctx context.Context, a *store.Alert) bool {
if err := ev.o.Store.UpsertAlertState(ctx, a); err != nil {
ev.log.Error("persist alert state", "rule", a.Rule, "key", a.SeriesKey, "err", err)
return false
}
return true
}
func tptr(t time.Time) *time.Time { return &t }
// apply handles one reading (below or above) for an entity; a is its active row or nil.
func (ev *Evaluator) apply(ctx context.Context, now time.Time, r *compiledRule, rd reading, thr float64, a *store.Alert) {
ent := rd.ent
breach := r.breach(rd.value, thr)
if a != nil && ev.flapExpired(now, a) {
ev.exitFlap(now, a)
if a.State == store.AlertResolved { // closed by the flap exit; this reading starts afresh
ev.finish(ctx, now, r, a, ent, rd.value, thr)
a = nil
}
}
if a == nil {
if !breach {
return
}
a = ev.newPending(ctx, now, r, ent)
}
a.NodataCount = 0
ev.clearStale(now, a)
if a.PendingSince == nil {
a.PendingSince = tptr(now)
}
if !ev.advance(ctx, now, r, a, breach) {
return
}
ev.finish(ctx, now, r, a, ent, rd.value, thr)
}
// newPending starts an incident row for ent, inheriting its flap history.
func (ev *Evaluator) newPending(ctx context.Context, now time.Time, r *compiledRule, ent Entity) *store.Alert {
a := &store.Alert{
Rule: r.Name, SeriesKey: ent.Key(), DeviceSlug: ent.Device, Iface: ent.Iface, Component: ent.Component,
Severity: r.Severity, State: store.AlertPending, BaseState: store.AlertPending, PendingSince: tptr(now),
FlapChanges: "[]",
}
// Flap history survives across incident rows.
if prev, err := ev.o.Store.LatestAlertBySeries(ctx, a.SeriesKey); err == nil {
a.FlapChanges = prev.FlapChanges
}
return a
}
// clearStale lets a real reading take a stale row back to its prior state.
func (ev *Evaluator) clearStale(now time.Time, a *store.Alert) {
if a.BaseState != store.AlertStale {
return
}
a.BaseState = a.PriorState
a.PriorState = ""
if a.BaseState == store.AlertPending {
a.PendingSince = tptr(now) // the old timer spans a gap we cannot vouch for
}
if !ev.inFlap(now, a) {
a.State = a.BaseState
}
}
// advance moves the row's base state according to the reading. It reports
// false when the row was dropped and there is nothing to persist.
func (ev *Evaluator) advance(ctx context.Context, now time.Time, r *compiledRule, a *store.Alert, breach bool) bool {
switch a.BaseState {
case store.AlertPending:
return ev.advancePending(ctx, now, r, a, breach)
case store.AlertFiring:
if !breach {
ev.resolve(now, a)
}
case store.AlertResolved: // only inside a flapping period
if breach {
a.BaseState, a.PendingSince, a.ClearedAt = store.AlertPending, tptr(now), nil
if r.For <= 0 {
ev.fire(now, a)
}
}
}
return true
}
func (ev *Evaluator) advancePending(ctx context.Context, now time.Time, r *compiledRule, a *store.Alert, breach bool) bool {
switch {
case !breach && !ev.inFlap(now, a):
if a.ID != 0 {
if err := ev.o.Store.DeletePendingAlert(ctx, a.ID); err != nil {
ev.log.Error("drop pending alert", "err", err)
}
}
return false // never fired: nothing to keep
case !breach:
a.BaseState = store.AlertResolved
a.ClearedAt = tptr(now)
case now.Sub(*a.PendingSince) >= r.For:
ev.fire(now, a)
}
return true
}
// finish refreshes the descriptive columns, persists and delivers.
func (ev *Evaluator) finish(ctx context.Context, now time.Time, r *compiledRule, a *store.Alert, ent Entity, v, thr float64) {
a.Value, a.Threshold, a.LastEvalAt = v, thr, tptr(now)
data := TemplateData{Rule: r.Name, Severity: r.Severity, Device: ent.Device, Iface: ent.Iface, Component: ent.Component,
Value: v, Threshold: thr, Op: r.Op, For: r.For}
a.Summary, a.Description = r.render(r.summary, data), r.render(r.desc, data)
ev.persistAndDeliver(ctx, now, a, ent)
}
func (ev *Evaluator) persistAndDeliver(ctx context.Context, now time.Time, a *store.Alert, ent Entity) {
if !ev.save(ctx, a) {
return
}
ev.deliver(ctx, now, a, ent)
}
func (ev *Evaluator) fire(now time.Time, a *store.Alert) {
a.BaseState = store.AlertFiring
a.ClearedAt = nil
if a.FiredAt == nil {
a.FiredAt = tptr(now)
}
ev.changed(now, a, NotifyFiring)
}
func (ev *Evaluator) resolve(now time.Time, a *store.Alert) {
a.BaseState = store.AlertResolved
a.ClearedAt = tptr(now)
ev.changed(now, a, NotifyResolved)
}
// changed records a firing/resolved change: it sets the visible state, counts
// it for flap damping and chooses the notification (flapping replaces the
// change that tipped the count over).
func (ev *Evaluator) changed(now time.Time, a *store.Alert, notify string) {
if ev.inFlap(now, a) {
a.State = store.AlertFlapping
return // silent while flapping
}
times := parseChanges(a.FlapChanges)
cut := now.Add(-FlapWindow).UnixMilli()
kept := times[:0]
for _, t := range times {
if t > cut {
kept = append(kept, t)
}
}
kept = append(kept, now.UnixMilli())
if len(kept) > FlapMax {
a.State = store.AlertFlapping
a.FlappingUntil = tptr(now.Add(FlapHold))
a.NotifyPending = NotifyFlapping
} else {
a.State = a.BaseState
a.NotifyPending = notify
}
a.FlapChanges = encodeChanges(kept)
}
func parseChanges(s string) []int64 {
var t []int64
_ = json.Unmarshal([]byte(s), &t)
return t
}
func encodeChanges(t []int64) string {
b, _ := json.Marshal(t)
return string(b)
}
func (ev *Evaluator) inFlap(now time.Time, a *store.Alert) bool {
return a.FlappingUntil != nil && now.Before(*a.FlappingUntil)
}
func (ev *Evaluator) flapExpired(now time.Time, a *store.Alert) bool {
return a.FlappingUntil != nil && !now.Before(*a.FlappingUntil)
}
// exitFlap ends a flapping period: the row takes its underlying state and one
// notification with that state goes out (a pending or resolved base reads as
// resolved, since nothing is firing).
func (ev *Evaluator) exitFlap(now time.Time, a *store.Alert) {
a.FlappingUntil = nil
a.FlapChanges = "[]"
switch a.BaseState {
case store.AlertFiring:
a.State = store.AlertFiring
a.NotifyPending = NotifyFiring
default:
a.BaseState, a.State = store.AlertResolved, store.AlertResolved
if a.ClearedAt == nil {
a.ClearedAt = tptr(now)
}
a.PendingSince = nil
a.NotifyPending = NotifyResolved
}
}
// nodata handles an active row whose series was absent from a successful query.
func (ev *Evaluator) nodata(ctx context.Context, now time.Time, r *compiledRule, a *store.Alert) {
ent := entityOf(a)
if ev.flapExpired(now, a) {
ev.exitFlap(now, a)
ev.persistAndDeliver(ctx, now, a, ent)
if !isActive(a.State) {
return
}
}
a.NodataCount++
a.LastEvalAt = tptr(now)
if a.NodataCount >= StaleAfter && a.BaseState != store.AlertStale && a.State != store.AlertFlapping {
a.PriorState = a.BaseState
a.BaseState, a.State = store.AlertStale, store.AlertStale
a.NotifyPending = NotifyStale
}
ev.persistAndDeliver(ctx, now, a, ent)
}
// retire closes a row for a device, interface or rule that no longer exists.
// It is not a resolution and sends nothing.
func (ev *Evaluator) retire(ctx context.Context, now time.Time, a *store.Alert) {
a.State, a.BaseState, a.PriorState = store.AlertRetired, store.AlertRetired, ""
a.ClearedAt, a.NotifyPending, a.FlappingUntil = tptr(now), "", nil
ev.save(ctx, a)
}

View File

@@ -16,6 +16,8 @@ import (
"github.com/danielgtaylor/huma/v2/adapters/humago"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/store"
)
@@ -50,6 +52,13 @@ type Deps struct {
Registry *selfmon.Registry // served at /metrics; default selfmon.Default
Logger *slog.Logger // default slog.Default()
SNMPPort int // dev/test override of UDP 161; 0 = 161
// Profiles select a device's profile at seed discovery. Nil: no profile matching.
Profiles *profile.Set
// PollerStatus feeds GET /poller/health. *poller.Poller's Status fits.
PollerStatus func() []poller.DeviceStatus
// AfterDiscover is called after POST /devices/{slug}/discover succeeded, e.g. to make the poller reload that device.
AfterDiscover func(deviceID int64)
// DiscoveryTimeout overrides the 30 s seed discovery limit (tests).
DiscoveryTimeout time.Duration
@@ -85,6 +94,7 @@ func New(d Deps) http.Handler {
api := humago.New(mux, cfg)
s.registerDevices(api)
s.registerOps(api)
s.registerQuery(api)
s.registerMetrics(api)
return problemFallback(mux)

View File

@@ -16,6 +16,7 @@ import (
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/selfmon"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/store"
@@ -55,6 +56,9 @@ type env struct {
q *querier
ref *refresher
reg *selfmon.Registry
afterID atomic.Int64
status []poller.DeviceStatus
}
func newEnv(t *testing.T, port int) *env {
@@ -62,7 +66,9 @@ func newEnv(t *testing.T, port int) *env {
e := &env{st: testutil.OpenStore(t, clk), q: &querier{body: []byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)},
ref: &refresher{}, reg: selfmon.New()}
e.h = api.New(api.Deps{Store: e.st, Querier: e.q, Poller: e.ref, Clock: clk, Registry: e.reg, SNMPPort: port,
DiscoveryTimeout: 3 * time.Second})
DiscoveryTimeout: 3 * time.Second,
PollerStatus: func() []poller.DeviceStatus { return e.status },
AfterDiscover: func(id int64) { e.afterID.Store(id) }})
return e
}

View File

@@ -3,13 +3,16 @@ package api
import (
"context"
"fmt"
"net/http"
"net/netip"
"slices"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/snmp"
"github.com/v10networks/nmon/internal/snmptarget"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/ulid"
)
@@ -25,16 +28,33 @@ type SNMPBody struct {
V3PrivProto string `json:"v3_priv_proto,omitempty" doc:"DES, AES (default), AES192 or AES256."`
V3PrivPass string `json:"v3_priv_pass,omitempty"`
V3Context string `json:"v3_context,omitempty"`
Port int `json:"port,omitempty" minimum:"1" maximum:"65535" doc:"UDP port; default 161."`
}
// AddDeviceBody is the body of POST /api/v1/devices.
type AddDeviceBody struct {
Name string `json:"name" minLength:"1" maxLength:"255" example:"core-sw1"`
Addresses []string `json:"addresses" minItems:"1" doc:"Management addresses; an IPv6 address is preferred over IPv4." example:"[\"2001:db8::1\"]"`
Addresses []string `json:"addresses" minItems:"1" doc:"Management addresses; an IPv6 address is preferred over IPv4." example:"[\"2001:db8::1\"]"` //nolint:lll // struct tag, cannot wrap
Kind string `json:"kind,omitempty" enum:"network,vm,container,check_only" default:"network"`
Site string `json:"site,omitempty"`
Role string `json:"role,omitempty"`
SNMP *SNMPBody `json:"snmp,omitempty" doc:"Required for kind network."`
// Reattach and CreateNew answer a 409 reattach_candidates response.
Reattach string `json:"reattach,omitempty" doc:"Slug of a retired device to restore instead of creating a new one (its slug and interface IDs come back)."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
CreateNew bool `json:"create_new,omitempty" doc:"Create a new device although a retired one matches the address or sysName."`
}
// reattachCandidate is a retired device offered in a 409.
type reattachCandidate struct {
Slug string `json:"slug"`
Name string `json:"name"`
RetiredAt time.Time `json:"retired_at"`
}
// reattachProblem is the 409 problem with the retired devices the client may restore.
type reattachProblem struct {
huma.ErrorModel
Candidates []reattachCandidate `json:"reattach_candidates"`
}
// InterfaceView is an interface as the API shows it, addressed by ULID.
@@ -84,10 +104,11 @@ type deviceOut struct{ Body DeviceView }
func (s *server) registerDevices(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "add-device", Method: "POST", Path: "/api/v1/devices", Tags: []string{"devices"},
Summary: "Add a device",
Description: "Creates the credential, device and addresses, runs seed discovery synchronously (30 s limit) and starts polling. A device that does not answer SNMP is rejected with 502 and nothing is stored.",
DefaultStatus: 201,
Errors: []int{409, 422, 502},
Summary: "Add a device",
Description: "Creates the credential, device and addresses, runs seed discovery synchronously (30 s limit) and " +
"starts polling. A device that does not answer SNMP is rejected with 502 and nothing is stored.",
DefaultStatus: http.StatusCreated,
Errors: []int{http.StatusConflict, http.StatusUnprocessableEntity, http.StatusBadGateway},
}, s.addDevice)
huma.Register(api, huma.Operation{
OperationID: "get-device", Method: "GET", Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
@@ -96,75 +117,32 @@ func (s *server) registerDevices(api huma.API) {
}, s.getDevice)
}
func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }) (*deviceOut, error) {
b := in.Body
kind := b.Kind
if kind == "" {
kind = store.KindNetwork
}
var addrs []netip.Addr
for _, a := range b.Addresses {
ip, err := netip.ParseAddr(a)
if err != nil {
return nil, huma.Error422UnprocessableEntity(fmt.Sprintf("address %q is not an IP address", a))
}
addrs = append(addrs, ip.WithZone("").Unmap())
}
if len(addrs) == 0 {
return nil, huma.Error422UnprocessableEntity("at least one address is required")
}
if kind == store.KindNetwork && b.SNMP == nil {
return nil, huma.Error422UnprocessableEntity("snmp credentials are required for kind network")
}
var (
cred *store.SNMPCredential
res *discovery.Result
)
if b.SNMP != nil {
var err error
if cred, err = s.credential(b.SNMP); err != nil {
return nil, err
}
pref := addrs[0]
for _, a := range addrs {
if a.Is6() {
pref = a
break
}
}
if res, err = s.collect(ctx, pref, cred); err != nil {
return nil, err
}
}
// addRequest is an AddDeviceBody after validation: defaults applied and
// addresses parsed.
type addRequest struct {
AddDeviceBody
kind string
addrs []netip.Addr
strs []string // addrs in canonical text form, as stored
}
var slug string
err := s.Store.WithTx(ctx, func(tx store.Store) error {
d := &store.Device{Name: b.Name, Kind: kind, Site: b.Site, Role: b.Role, Enabled: true, Profile: "generic"}
if cred != nil {
if err := tx.CreateCredential(ctx, cred); err != nil {
return err
}
d.SNMPCredentialID = &cred.ID
}
if err := tx.CreateDevice(ctx, d); err != nil {
return err
}
strs := make([]string, len(addrs))
for i, a := range addrs {
strs[i] = a.String()
}
if err := tx.SetAddresses(ctx, d.ID, strs); err != nil {
return err
}
if res != nil {
if err := discovery.Apply(ctx, tx, d, res, s.Clock); err != nil {
return err
}
}
slug = d.Slug
return tx.AppendAudit(ctx, "anonymous", "device.add", d.Slug, nil,
map[string]any{"name": d.Name, "kind": d.Kind, "addresses": strs})
})
// addDevice validates the request, collects from the device before anything is
// stored, decides between a new device and a reattach, then persists in one
// transaction.
func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }) (*deviceOut, error) {
req, err := validateAdd(in.Body)
if err != nil {
return nil, err
}
cred, res, err := s.credentialAndSeed(ctx, req)
if err != nil {
return nil, err
}
restore, err := s.reattachDecision(ctx, req, res)
if err != nil {
return nil, err
}
slug, err := s.persistDevice(ctx, req, cred, res, restore)
if err != nil {
return nil, s.storeErr(err)
}
@@ -172,6 +150,183 @@ func (s *server) addDevice(ctx context.Context, in *struct{ Body AddDeviceBody }
return s.view(ctx, slug)
}
// validateAdd applies the kind default and parses the addresses.
func validateAdd(b AddDeviceBody) (*addRequest, error) {
req := &addRequest{AddDeviceBody: b, kind: b.Kind}
if req.kind == "" {
req.kind = store.KindNetwork
}
for _, a := range b.Addresses {
ip, err := netip.ParseAddr(a)
if err != nil {
return nil, huma.Error422UnprocessableEntity(fmt.Sprintf("address %q is not an IP address", a))
}
ip = ip.WithZone("").Unmap()
req.addrs = append(req.addrs, ip)
req.strs = append(req.strs, ip.String())
}
if len(req.addrs) == 0 {
return nil, huma.Error422UnprocessableEntity("at least one address is required")
}
if req.kind == store.KindNetwork && b.SNMP == nil {
return nil, huma.Error422UnprocessableEntity("snmp credentials are required for kind network")
}
return req, nil
}
// credentialAndSeed builds the credential from the request and runs the seed
// walk against the preferred address. Both are nil when the request has no
// SNMP block.
func (s *server) credentialAndSeed(ctx context.Context, req *addRequest) (*store.SNMPCredential, *discovery.Result, error) {
if req.SNMP == nil {
return nil, nil, nil
}
cred, err := s.credential(req.SNMP)
if err != nil {
return nil, nil, err
}
res, err := s.collect(ctx, req.preferredAddr(), s.port(req.SNMP.Port), cred)
if err != nil {
return nil, nil, err
}
return cred, res, nil
}
// preferredAddr is the first IPv6 address, else the first address.
func (r *addRequest) preferredAddr() netip.Addr {
for _, a := range r.addrs {
if a.Is6() {
return a
}
}
return r.addrs[0]
}
// reattachDecision looks for retired devices matching the request. It returns
// the one to restore, nil for a brand-new device, or a 409 offering the
// candidates when the client has not chosen.
func (s *server) reattachDecision(ctx context.Context, req *addRequest, res *discovery.Result) (*discovery.ReattachCandidate, error) {
if req.CreateNew {
return nil, nil
}
sysName := ""
if res != nil {
sysName = res.SysName
}
cands, err := discovery.FindReattachCandidates(ctx, s.Store, req.strs, sysName)
if err != nil {
return nil, s.storeErr(err)
}
if req.Reattach != "" {
i := slices.IndexFunc(cands, func(c discovery.ReattachCandidate) bool { return c.Slug == req.Reattach })
if i < 0 {
return nil, huma.Error422UnprocessableEntity(
"reattach: " + req.Reattach + " is not a retired device matching this address or sysName")
}
return &cands[i], nil
}
if len(cands) == 0 {
return nil, nil
}
if err := discovery.OfferReattach(ctx, s.Store, cands, req.Name, req.strs, sysName); err != nil {
return nil, s.storeErr(err)
}
return nil, reattachOffer(cands)
}
// reattachOffer is the 409 listing the retired devices the client may restore.
func reattachOffer(cands []discovery.ReattachCandidate) *reattachProblem {
p := &reattachProblem{Candidates: make([]reattachCandidate, len(cands))}
p.Status, p.Title = http.StatusConflict, http.StatusText(http.StatusConflict)
p.Detail = "a retired device has this address or sysName; send reattach: <slug> to restore it or create_new: true to add a new device"
for i, c := range cands {
p.Candidates[i] = reattachCandidate{Slug: c.Slug, Name: c.Name, RetiredAt: c.RetiredAt}
}
return p
}
// persistDevice writes the credential, device, addresses, seeded inventory and
// audit row in one transaction and returns the device slug.
func (s *server) persistDevice(
ctx context.Context, req *addRequest, cred *store.SNMPCredential, res *discovery.Result, restore *discovery.ReattachCandidate,
) (string, error) {
var slug string
err := s.Store.WithTx(ctx, func(tx store.Store) error {
d, err := s.writeDevice(ctx, tx, req, cred, res, restore)
if err != nil {
return err
}
slug = d.Slug
action := "device.add"
if restore != nil {
action = "device.reattach"
}
return tx.AppendAudit(ctx, "anonymous", action, d.Slug, nil,
map[string]any{"name": d.Name, "kind": d.Kind, "addresses": req.strs})
})
return slug, err
}
// writeDevice stores the credential, the device row, its addresses and the
// seeded inventory inside tx.
func (s *server) writeDevice(
ctx context.Context, tx store.Store, req *addRequest, cred *store.SNMPCredential, res *discovery.Result,
restore *discovery.ReattachCandidate,
) (*store.Device, error) {
d, err := s.deviceRow(ctx, tx, req, restore)
if err != nil {
return nil, err
}
if cred != nil {
if err := tx.CreateCredential(ctx, cred); err != nil {
return nil, err
}
d.SNMPCredentialID = &cred.ID
}
if restore != nil {
err = tx.UpdateDevice(ctx, d)
} else {
err = tx.CreateDevice(ctx, d)
}
if err != nil {
return nil, err
}
if err := tx.SetAddresses(ctx, d.ID, req.strs); err != nil {
return nil, err
}
if res != nil {
if _, err := discovery.Apply(ctx, tx, d, res, s.Clock); err != nil {
return nil, err
}
}
return d, nil
}
// deviceRow is the device to write: a fresh row, or the restored one with the
// request's name, kind and (when given) site and role laid over it.
func (s *server) deviceRow(
ctx context.Context, tx store.Store, req *addRequest, restore *discovery.ReattachCandidate,
) (*store.Device, error) {
d := &store.Device{Name: req.Name, Kind: req.kind, Site: req.Site, Role: req.Role, Enabled: true, Profile: "generic"}
if restore != nil {
var err error
if d, err = tx.Reattach(ctx, restore.DeviceID); err != nil {
return nil, err
}
d.Name, d.Kind, d.Enabled = req.Name, req.kind, true
if req.Site != "" {
d.Site = req.Site
}
if req.Role != "" {
d.Role = req.Role
}
}
if req.SNMP != nil {
d.SNMPPort = req.SNMP.Port
}
return d, nil
}
// credential validates the request's SNMP block and returns a fresh credential.
func (s *server) credential(b *SNMPBody) (*store.SNMPCredential, error) {
c := &store.SNMPCredential{
@@ -201,15 +356,15 @@ func (s *server) credential(b *SNMPBody) (*store.SNMPCredential, error) {
}
// collect runs the seed walk against addr before anything is stored.
func (s *server) collect(ctx context.Context, addr netip.Addr, cred *store.SNMPCredential) (*discovery.Result, error) {
func (s *server) collect(ctx context.Context, addr netip.Addr, port int, cred *store.SNMPCredential) (*discovery.Result, error) {
dctx, cancel := context.WithTimeout(ctx, s.DiscoveryTimeout)
defer cancel()
sess, err := snmp.Dial(dctx, discovery.Target(addr, s.SNMPPort, cred, s.Clock), "discovery")
sess, err := snmp.Dial(dctx, snmptarget.For(addr, port, cred, s.Clock), "discovery")
if err != nil {
return nil, huma.Error422UnprocessableEntity("snmp: " + err.Error())
}
defer sess.Close()
res, err := discovery.Collect(dctx, sess, s.Clock)
defer func() { _ = sess.Close() }() // read-only session; nothing to lose on close
res, err := discovery.Collect(dctx, sess, s.Clock, discovery.Options{Profiles: s.Profiles})
if err != nil {
return nil, huma.Error502BadGateway("discovery of " + addr.String() + " failed: " + err.Error())
}
@@ -279,3 +434,11 @@ func utcp(t *time.Time) *time.Time {
u := t.UTC()
return &u
}
// port is the device's SNMP port unless the dev-only --snmp-port override is set.
func (s *server) port(p int) int {
if s.SNMPPort != 0 {
return s.SNMPPort
}
return p
}

View File

@@ -1,7 +1,9 @@
package api
import (
"errors"
"fmt"
"net/http"
"testing"
"github.com/danielgtaylor/huma/v2"
@@ -17,11 +19,12 @@ func TestStoreErrorMapping(t *testing.T) {
status int
detail string
}{
{fmt.Errorf("%w: slug taken", store.ErrConflict), 409, "slug taken"},
{fmt.Errorf("%w: bad kind", store.ErrInvalid), 422, "bad kind"},
{fmt.Errorf("wrapped: %w", store.ErrNotFound), 404, "not found"},
{fmt.Errorf("%w: slug taken", store.ErrConflict), http.StatusConflict, "slug taken"},
{fmt.Errorf("%w: bad kind", store.ErrInvalid), http.StatusUnprocessableEntity, "bad kind"},
{fmt.Errorf("wrapped: %w", store.ErrNotFound), http.StatusNotFound, "not found"},
} {
se, ok := s.storeErr(tc.err).(huma.StatusError)
var se huma.StatusError
ok := errors.As(s.storeErr(tc.err), &se)
if !ok || se.GetStatus() != tc.status || se.Error() != tc.detail {
t.Errorf("%v: got %v, want %d %q", tc.err, se, tc.status, tc.detail)
}

179
internal/api/ops.go Normal file
View File

@@ -0,0 +1,179 @@
package api
import (
"context"
"encoding/json"
"net/http"
"time"
"github.com/danielgtaylor/huma/v2"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/store"
)
// TableView says how one discovery table went.
type TableView struct {
Name string `json:"name"`
Status string `json:"status" doc:"ok, partial (row limit or timeout: previous rows kept), error or skipped."`
Rows int `json:"rows"`
Detail string `json:"detail,omitempty"`
}
// DiscoverView summarises one discovery run.
type DiscoverView struct {
Profile string `json:"profile"`
ProfileMissing string `json:"profile_missing,omitempty" doc:"The profile the device asked for when it no longer exists; generic was used."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
Partial bool `json:"partial" doc:"A table overran its row limit or timeout; its previous result was kept."`
Tables []TableView `json:"tables"`
InterfacesMatched int `json:"interfaces_matched"`
InterfacesNew int `json:"interfaces_new"`
InterfacesRetired int `json:"interfaces_retired"`
InterfacesSplit int `json:"interfaces_split" doc:"Interfaces that could not be matched one-to-one; both rows are kept and an identity review is open."` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
Unretired int `json:"interfaces_unretired"`
IdentityReviews int `json:"identity_reviews_opened"`
HardwareReplaced bool `json:"hardware_replaced"`
Warnings []string `json:"warnings"`
}
// PollerDevice is the poller's view of one device. It uses the slug, never the numeric ID.
type PollerDevice struct {
Device string `json:"device" doc:"Device slug."`
State string `json:"state" doc:"ok or degraded."`
Profile string `json:"profile"`
Interfaces int `json:"interfaces" doc:"Interfaces polled."`
VarbindsPerRequest int `json:"varbinds_per_request"`
ConsecutiveFailures int `json:"consecutive_failures"`
MissedDeadlines int `json:"missed_deadlines"`
IntervalSeconds float64 `json:"interval_seconds" doc:"Current cadence of the hot group, backoff included."`
BaseIntervalSeconds float64 `json:"base_interval_seconds"`
LastCycleSeconds float64 `json:"last_cycle_duration_seconds"`
LastCycle *time.Time `json:"last_cycle,omitempty"`
NextCycle *time.Time `json:"next_cycle,omitempty"`
LastError string `json:"last_error,omitempty"`
}
// IdentityReviewView is an open identity review.
type IdentityReviewView struct {
ID int64 `json:"id"`
Device string `json:"device,omitempty" doc:"Device slug."`
Kind string `json:"kind" doc:"ambiguous_match, hardware_replaced or reattach_candidate."`
Candidates json.RawMessage `json:"candidates"`
Evidence json.RawMessage `json:"evidence"`
CreatedAt time.Time `json:"created_at"`
}
func (s *server) registerOps(api huma.API) {
huma.Register(api, huma.Operation{
OperationID: "discover-device", Method: "POST", Path: "/api/v1/devices/{slug}/discover", Tags: []string{"devices"},
Summary: "Run discovery now",
Description: "Walks the device synchronously (bounded by the discovery timeout), applies the identity matcher and " +
"returns what changed. Admin-only once authentication arrives (M6).",
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusBadGateway},
}, s.discoverDevice)
huma.Register(api, huma.Operation{
OperationID: "poller-health", Method: "GET", Path: "/api/v1/poller/health", Tags: []string{"poller"},
Summary: "Poller state of every polled device",
}, s.pollerHealth)
huma.Register(api, huma.Operation{
OperationID: "list-identity-reviews", Method: "GET", Path: "/api/v1/identity-reviews", Tags: []string{"devices"},
Summary: "Open identity reviews",
}, s.identityReviews)
}
func (s *server) discoverDevice(ctx context.Context, in *struct {
Slug string `path:"slug" doc:"Device slug."`
}) (*struct{ Body DiscoverView }, error) {
dev, err := s.Store.GetDeviceBySlug(ctx, in.Slug)
if err != nil {
return nil, s.storeErr(err)
}
if dev.PurgedAt != nil {
return nil, huma.Error404NotFound("not found")
}
if dev.RetiredAt != nil || dev.Kind != store.KindNetwork || dev.SNMPCredentialID == nil {
return nil, huma.Error409Conflict("device is retired or has no SNMP credential; nothing to discover")
}
dctx, cancel := context.WithTimeout(ctx, s.DiscoveryTimeout)
defer cancel()
deps := discovery.Deps{Store: s.Store, Profiles: s.Profiles, Clock: s.Clock, SNMPPort: s.SNMPPort, Logger: s.Logger}
rep, err := discovery.Discover(dctx, deps, dev)
if err != nil {
return nil, huma.Error502BadGateway("discovery of " + in.Slug + " failed: " + err.Error())
}
if s.AfterDiscover != nil {
s.AfterDiscover(dev.ID)
}
return &struct{ Body DiscoverView }{DiscoverViewOf(rep)}, nil
}
// DiscoverViewOf converts a discovery report.
func DiscoverViewOf(r *discovery.Report) DiscoverView {
v := DiscoverView{
Profile: r.Profile, ProfileMissing: r.ProfileMissing, Partial: r.Partial, Tables: []TableView{},
InterfacesMatched: r.Matched, InterfacesNew: r.New, InterfacesRetired: r.Retired, InterfacesSplit: r.Split,
Unretired: r.Unretired, IdentityReviews: len(r.Reviews), HardwareReplaced: r.HardwareReplaced,
Warnings: nonNil(r.Warnings),
}
for _, t := range r.Tables {
v.Tables = append(v.Tables, TableView{Name: t.Name, Status: t.Status, Rows: t.Rows, Detail: t.Detail})
}
return v
}
func (s *server) pollerHealth(context.Context, *struct{}) (*struct{ Body []PollerDevice }, error) {
out := []PollerDevice{}
if s.PollerStatus != nil {
for _, d := range s.PollerStatus() {
out = append(out, pollerDevice(d))
}
}
return &struct{ Body []PollerDevice }{out}, nil
}
func pollerDevice(d poller.DeviceStatus) PollerDevice {
return PollerDevice{
Device: d.Device, State: d.State, Profile: d.Profile, Interfaces: d.Interfaces, VarbindsPerRequest: d.Varbinds,
ConsecutiveFailures: d.ConsecutiveFailures, MissedDeadlines: d.MissedDeadlines,
IntervalSeconds: d.Interval.Seconds(), BaseIntervalSeconds: d.BaseInterval.Seconds(),
LastCycleSeconds: d.LastCycleDuration.Seconds(), LastCycle: tp(d.LastCycle), NextCycle: tp(d.NextCycle),
LastError: d.LastError,
}
}
func tp(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
u := t.UTC()
return &u
}
func (s *server) identityReviews(ctx context.Context, _ *struct{}) (*struct{ Body []IdentityReviewView }, error) {
rows, _, err := s.Store.ListOpenIdentityReviews(ctx, store.Page{Limit: store.MaxLimit})
if err != nil {
return nil, s.storeErr(err)
}
out := []IdentityReviewView{}
for _, r := range rows {
v := IdentityReviewView{
ID: r.ID, Kind: r.Kind, Candidates: rawJSON(r.Candidates), Evidence: rawJSON(r.Evidence), CreatedAt: r.CreatedAt.UTC(),
}
if r.DeviceID != nil {
if d, err := s.Store.GetDeviceByID(ctx, *r.DeviceID); err == nil {
v.Device = d.Slug
}
}
out = append(out, v)
}
return &struct{ Body []IdentityReviewView }{out}, nil
}
func rawJSON(s string) json.RawMessage {
if json.Valid([]byte(s)) {
return json.RawMessage(s)
}
b, _ := json.Marshal(s)
return b
}

66
internal/api/ops_test.go Normal file
View File

@@ -0,0 +1,66 @@
package api_test
import (
"encoding/json"
"fmt"
"net/http"
"strings"
"testing"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/testutil"
)
func TestDiscoverEndpointReportsAndRefreshes(t *testing.T) {
a := testutil.StartAgent(t, simagent.Config{
Recording: fixturegen.Generic(fixturegen.Options{Ports: 6, SysName: "d1", HCCounters: true}), Community: community,
})
e := newEnv(t, int(a.Addr().Port()))
body := fmt.Sprintf(`{"name":"D One","addresses":["::1"],"snmp":{"version":"v2c","community":%q}}`, community)
rec := e.do("POST", "/api/v1/devices", body)
if rec.Code != http.StatusCreated {
t.Fatalf("POST: %d %s", rec.Code, rec.Body)
}
var dev api.DeviceView
_ = json.Unmarshal(rec.Body.Bytes(), &dev)
rec = e.do("POST", "/api/v1/devices/"+dev.Slug+"/discover", "")
if rec.Code != http.StatusOK {
t.Fatalf("discover: %d %s", rec.Code, rec.Body)
}
var rep api.DiscoverView
if err := json.Unmarshal(rec.Body.Bytes(), &rep); err != nil {
t.Fatal(err)
}
if rep.InterfacesMatched != 6 || rep.InterfacesNew != 0 || rep.InterfacesRetired != 0 || rep.Partial || len(rep.Tables) == 0 {
t.Errorf("report %+v", rep)
}
if e.afterID.Load() == 0 {
t.Error("AfterDiscover not called")
}
isProblem(t, e.do("POST", "/api/v1/devices/no-such-device/discover", ""), 404)
}
func TestPollerHealthUsesSlugs(t *testing.T) {
e := newEnv(t, 0)
rec := e.do("GET", "/api/v1/poller/health", "")
if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != "[]" {
t.Fatalf("empty health: %d %s", rec.Code, rec.Body)
}
e.status = []poller.DeviceStatus{{Device: "core-1", DeviceID: 77, State: "degraded", Varbinds: 20, Profile: "juniper-junos"}}
rec = e.do("GET", "/api/v1/poller/health", "")
if rec.Code != 200 || !strings.Contains(rec.Body.String(), `"device":"core-1"`) || strings.Contains(rec.Body.String(), "77") {
t.Fatalf("health: %s", rec.Body)
}
}
func TestIdentityReviewsList(t *testing.T) {
e := newEnv(t, 0)
rec := e.do("GET", "/api/v1/identity-reviews", "")
if rec.Code != 200 || strings.TrimSpace(rec.Body.String()) != "[]" {
t.Fatalf("reviews: %d %s", rec.Code, rec.Body)
}
}

View File

@@ -109,7 +109,8 @@ func (s *server) finish(ctx context.Context, body []byte, err error) (*huma.Stre
return nil, huma.Error502BadGateway("metric store error")
}
if n := countSeries(body); n > MaxSeries {
return nil, huma.Error422UnprocessableEntity("query returned " + itoa(n) + " series; the limit is " + itoa(MaxSeries) + ", narrow the selector")
return nil, huma.Error422UnprocessableEntity(
"query returned " + itoa(n) + " series; the limit is " + itoa(MaxSeries) + ", narrow the selector")
}
return raw(body), nil
}

View File

@@ -0,0 +1,123 @@
package api_test
import (
"context"
"encoding/json"
"fmt"
"net/http"
"slices"
"testing"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/testutil"
)
func TestReattachRetiredDevice(t *testing.T) {
a := testutil.StartAgent(t, simagent.Config{
Recording: fixturegen.Generic(fixturegen.Options{Ports: 6, SysName: "core1", HCCounters: true}), Community: community,
})
e := newEnv(t, int(a.Addr().Port()))
ctx := context.Background()
body := func(extra string) string {
return fmt.Sprintf(`{"name":"Core","addresses":["::1"],"snmp":{"version":"v2c","community":%q}%s}`, community, extra)
}
rec := e.do("POST", "/api/v1/devices", body(""))
if rec.Code != http.StatusCreated {
t.Fatalf("first POST: %d %s", rec.Code, rec.Body)
}
var first api.DeviceView
if err := json.Unmarshal(rec.Body.Bytes(), &first); err != nil {
t.Fatal(err)
}
dev, err := e.st.GetDeviceBySlug(ctx, first.Slug)
if err != nil {
t.Fatal(err)
}
// A live device with this address is not a reattach candidate.
// Retire it, then add the same box again.
if err := e.st.RetireDevice(ctx, dev.ID); err != nil {
t.Fatal(err)
}
rec = e.do("POST", "/api/v1/devices", body(""))
isProblem(t, rec, http.StatusConflict)
var p struct {
Candidates []struct {
Slug, Name string
RetiredAt string `json:"retired_at"`
} `json:"reattach_candidates"`
}
if err := json.Unmarshal(rec.Body.Bytes(), &p); err != nil || len(p.Candidates) != 1 ||
p.Candidates[0].Slug != first.Slug || p.Candidates[0].RetiredAt == "" {
t.Fatalf("409 body: %s (%v)", rec.Body, err)
}
reviews, _, _ := e.st.ListOpenIdentityReviews(ctx, store.Page{})
if len(reviews) != 1 || reviews[0].Kind != store.ReviewReattachCandidate || *reviews[0].DeviceID != dev.ID {
t.Fatalf("reviews: %+v", reviews)
}
// Asking again does not pile up reviews.
isProblem(t, e.do("POST", "/api/v1/devices", body("")), http.StatusConflict)
if reviews, _, _ = e.st.ListOpenIdentityReviews(ctx, store.Page{}); len(reviews) != 1 {
t.Fatalf("%d reviews after a second offer", len(reviews))
}
// An unknown slug is rejected.
isProblem(t, e.do("POST", "/api/v1/devices", body(`,"reattach":"nope"`)), http.StatusUnprocessableEntity)
rec = e.do("POST", "/api/v1/devices", body(`,"reattach":"`+first.Slug+`"`))
if rec.Code != http.StatusCreated {
t.Fatalf("reattach POST: %d %s", rec.Code, rec.Body)
}
var again api.DeviceView
if err := json.Unmarshal(rec.Body.Bytes(), &again); err != nil {
t.Fatal(err)
}
if again.Slug != first.Slug || again.RetiredAt != nil || len(again.Interfaces) != 6 {
t.Fatalf("reattached: %+v", again)
}
ids := func(v api.DeviceView) []string {
var out []string
for _, i := range v.Interfaces {
out = append(out, i.ID)
}
slices.Sort(out)
return out
}
if !slices.Equal(ids(first), ids(again)) {
t.Fatalf("interface IDs changed:\n%v\n%v", ids(first), ids(again))
}
if devs, total, _ := e.st.ListDevices(ctx, store.DeviceFilter{IncludeRetired: true}, store.Page{}); total != 1 {
t.Fatalf("%d devices (%v), want 1", total, devs)
}
audit, _, _ := e.st.ListAudit(ctx, store.Page{})
if !slices.ContainsFunc(audit, func(a store.AuditEntry) bool { return a.Action == "device.reattach" && a.Object == first.Slug }) {
t.Errorf("no device.reattach audit row: %+v", audit)
}
}
func TestCreateNewDespiteRetired(t *testing.T) {
a := testutil.StartAgent(t, simagent.Config{Recording: fixturegen.Generic(fixturegen.Options{Ports: 4, SysName: "x1"}), Community: community})
e := newEnv(t, int(a.Addr().Port()))
ctx := context.Background()
body := fmt.Sprintf(`{"name":"X","addresses":["::1"],"snmp":{"version":"v2c","community":%q},"create_new":true}`, community)
var slugs []string
for range 2 {
rec := e.do("POST", "/api/v1/devices", body)
if rec.Code != http.StatusCreated {
t.Fatalf("POST: %d %s", rec.Code, rec.Body)
}
var v api.DeviceView
_ = json.Unmarshal(rec.Body.Bytes(), &v)
slugs = append(slugs, v.Slug)
d, _ := e.st.GetDeviceBySlug(ctx, v.Slug)
if len(slugs) == 1 {
_ = e.st.RetireDevice(ctx, d.ID)
}
}
if slugs[0] == slugs[1] {
t.Fatalf("create_new reused slug %s", slugs[0])
}
}

View File

@@ -9,17 +9,14 @@ import (
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strings"
"time"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/secrets"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/traps"
"github.com/v10networks/nmon/internal/vmsup"
)
@@ -27,6 +24,7 @@ import (
const (
DefaultListen = "[::]:8443"
DefaultSecretKeyFile = "/etc/nmon/secret.key"
DefaultTrapListen = traps.DefaultAddr
)
const (
@@ -45,10 +43,26 @@ type App struct {
srv *http.Server
ln net.Listener
bg context.CancelFunc // poller and queue goroutines
pollDone chan struct{}
queuesWG chan struct{}
serveErr chan error
profiles *profile.Set
sched *discovery.Scheduler
traps *traps.Listener // nil when the trap receiver is disabled
serveErr chan error // the HTTP server stopped on its own
// stops holds one step per subsystem that came up, in start order. Stop runs
// them in reverse, so a subsystem goes down before the ones it depends on.
stops []stopStep
}
// stopStep shuts one subsystem down.
type stopStep struct {
name string
fn func(ctx context.Context) error
}
// onStop registers how to shut down the subsystem that was just started.
func (a *App) onStop(name string, fn func(ctx context.Context) error) {
a.stops = append(a.stops, stopStep{name, fn})
}
// Run starts nmon and blocks until ctx is cancelled or the HTTP server fails,
@@ -69,125 +83,47 @@ func Run(ctx context.Context, f config.Flags) error {
}
// Start brings every component up and returns once the API is listening. ctx
// bounds startup only; call Stop to shut down.
// bounds startup only; call Stop to shut down. A failed Start stops whatever
// had already come up.
//
// Startup order: inventory, metric stores, write queues, profiles, poller,
// discovery scheduler, trap receiver, HTTP API. Stop reverses it.
func Start(ctx context.Context, f config.Flags) (_ *App, err error) {
log := slog.Default()
if !f.InsecureHTTP {
return nil, errors.New("TLS and authentication arrive in M6; until then pass --insecure-http to run without them (development only)")
}
if f.DataDir == "" {
return nil, errors.New("--data-dir is required")
}
if f.Listen == "" {
f.Listen = DefaultListen
}
if f.SecretKeyFile == "" {
f.SecretKeyFile = DefaultSecretKeyFile
}
if f.MaxInflight <= 0 {
f.MaxInflight = poller.DefaultMaxInflight
}
dataDir, err := filepath.Abs(f.DataDir)
s, err := resolveSettings(f)
if err != nil {
return nil, err
}
f.DataDir = dataDir
keyFile, err := filepath.Abs(f.SecretKeyFile)
kr, err := loadKeyring(log, s)
if err != nil {
return nil, err
}
if inside(dataDir, keyFile) {
return nil, fmt.Errorf("--secret-key-file %s is inside --data-dir %s; a backup of the data dir must not contain the key", keyFile, dataDir)
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, err
}
cfg, err := config.Load(dataDir)
if err != nil {
return nil, err
}
driver := f.DBDriver
if driver == "" {
driver = store.DriverSQLite
}
dsn := f.DBDSN
if dsn == "" {
if driver != store.DriverSQLite {
return nil, fmt.Errorf("--db-dsn is required for --db-driver %s", driver)
}
dsn = config.InventoryDB(dataDir)
}
if err := ensureKey(log, keyFile, driver == store.DriverSQLite && fileExists(config.InventoryDB(dataDir))); err != nil {
return nil, err
}
kr, err := secrets.LoadKeyring(keyFile)
if err != nil {
return nil, fmt.Errorf("secret key: %w", err)
}
a := &App{log: log, serveErr: make(chan error, 1), pollDone: make(chan struct{}), queuesWG: make(chan struct{})}
a := &App{log: log, serveErr: make(chan error, 1)}
defer func() {
if err != nil {
sctx, cancel := context.WithTimeout(context.Background(), shutdownTimeout)
defer cancel()
_ = a.Stop(sctx)
_ = a.Stop(sctx) // the startup error is the one to report
}
}()
if a.st, err = store.Open(ctx, driver, dsn, kr, clock.Real()); err != nil {
return nil, fmt.Errorf("open inventory: %w", err)
}
if err = a.st.Migrate(ctx); err != nil {
return nil, fmt.Errorf("migrate inventory: %w", err)
}
a.sup, err = vmsup.New(vmsup.Options{
DataDir: dataDir, HotURL: f.VMHotURL, LongURL: f.VMLongURL,
HotRetention: cfg.Retention.Hot.D(), LongRetention: cfg.Retention.Long.D(), Logger: log,
OnEvent: func(e vmsup.Event) {
log.Warn("metric store event", "type", e.Type, "store", e.Store, "detail", e.Detail, "restarts", e.Restarts)
},
})
if err != nil {
if err = a.startStore(ctx, s, kr); err != nil {
return nil, err
}
sctx, cancel := context.WithTimeout(ctx, startTimeout)
defer cancel()
if err = a.sup.Start(sctx); err != nil {
a.sup = nil // Start leaves nothing running when it fails
return nil, fmt.Errorf("start metric stores: %w", err)
if err = a.startMetricStores(ctx, s); err != nil {
return nil, err
}
log.Info("metric stores up", "hot", a.sup.HotURL(), "long", a.sup.LongURL())
bg, cancelBG := context.WithCancel(context.Background())
a.bg = cancelBG
a.hot = vmsup.NewQueue(vmsup.StoreHot, a.sup, vmsup.QueueOptions{})
a.long = vmsup.NewQueue(vmsup.StoreLong, a.sup, vmsup.QueueOptions{})
qdone := make(chan struct{}, 2)
for _, q := range []*vmsup.Queue{a.hot, a.long} {
go func() { q.Run(bg); qdone <- struct{}{} }()
a.startQueues()
if err = a.loadProfiles(s); err != nil {
return nil, err
}
go func() { <-qdone; <-qdone; close(a.queuesWG) }()
a.poller = poller.New(poller.Options{
Store: a.st, Writer: a.hot, Interval: f.PollInterval, MaxInflight: f.MaxInflight, SNMPPort: f.SNMPPort, Logger: log,
})
go func() { _ = a.poller.Run(bg); close(a.pollDone) }()
handler := api.New(api.Deps{
Store: a.st, Querier: vmsup.NewQuerier(a.sup), Poller: a.poller, SNMPPort: f.SNMPPort, Logger: log,
})
if a.ln, err = net.Listen("tcp", f.Listen); err != nil {
return nil, fmt.Errorf("listen: %w", err)
a.startPoller(s)
a.newScheduler(s)
a.newTraps(s)
a.startScheduler()
a.startTraps(s)
if err = a.startAPI(s); err != nil {
return nil, err
}
a.srv = &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute}
go func() {
if err := a.srv.Serve(a.ln); !errors.Is(err, http.ErrServerClosed) {
a.serveErr <- err
}
}()
log.Warn("serving plain HTTP without authentication (--insecure-http); do not expose this port", "addr", a.ln.Addr().String())
return a, nil
}
@@ -197,67 +133,17 @@ func (a *App) Addr() string { return a.ln.Addr().String() }
// HotURL is the base URL of the hot metric store.
func (a *App) HotURL() string { return a.sup.HotURL() }
// Stop shuts down: HTTP first, then the poller, then the metric stores (which
// flushes the write queues), then the inventory. It is safe after a failed Start.
// Stop shuts the subsystems down in reverse start order: HTTP, trap receiver,
// discovery scheduler, poller, write queues, metric stores (which flush the
// queues) and the inventory. It is safe after a failed Start and idempotent.
func (a *App) Stop(ctx context.Context) error {
var errs []error
if a.srv != nil {
errs = append(errs, a.srv.Shutdown(ctx))
}
if a.bg != nil {
a.bg()
<-a.pollDone
<-a.queuesWG
}
if a.sup != nil {
errs = append(errs, a.sup.Stop(ctx)) // flushes both queues
}
if a.st != nil {
errs = append(errs, a.st.Close())
for i := len(a.stops) - 1; i >= 0; i-- {
if err := a.stops[i].fn(ctx); err != nil {
errs = append(errs, fmt.Errorf("stop %s: %w", a.stops[i].name, err))
}
}
a.stops = nil
a.log.Info("stopped")
return errors.Join(errs...)
}
// ensureKey creates the key file on a first run. It refuses when an inventory
// already exists, since a new key could not decrypt its credentials.
func ensureKey(log *slog.Logger, path string, inventoryExists bool) error {
if _, err := os.Stat(path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if inventoryExists {
return fmt.Errorf("secret key file %s is missing but the inventory exists; restore the key file (a new key cannot decrypt the stored SNMP credentials)", path)
}
if err := secrets.GenerateKeyFile(path); err != nil {
return fmt.Errorf("generate secret key: %w", err)
}
log.Error("GENERATED A NEW SECRET KEY: back it up now; without it the SNMP credentials in the inventory cannot be recovered", "path", path)
return nil
}
// inside reports whether path is dir or below it.
func inside(dir, path string) bool {
rel, err := filepath.Rel(resolve(dir), resolve(path))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// resolve follows symlinks in the deepest existing parent of p.
func resolve(p string) string {
rest := ""
for q := p; ; q = filepath.Dir(q) {
if r, err := filepath.EvalSymlinks(q); err == nil {
return filepath.Join(r, rest)
}
if filepath.Dir(q) == q {
return p
}
rest = filepath.Join(filepath.Base(q), rest)
}
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}

View File

@@ -27,7 +27,7 @@ func TestStartRefusals(t *testing.T) {
t.Run(name, func(t *testing.T) {
a, err := Start(ctx, tc.f)
if err == nil {
a.Stop(ctx)
_ = a.Stop(ctx) // already failing the test
t.Fatal("started")
}
if !strings.Contains(err.Error(), tc.want) {

View File

@@ -173,3 +173,68 @@ func distinct(v []float64) []float64 {
}
return out
}
// TestServeDiscoverEndpointHealthAndTraps: the wiring added in M2: the discover
// endpoint, the poller health page and a trap receiver that binds.
func TestServeDiscoverEndpointHealthAndTraps(t *testing.T) {
rec, err := simagent.LoadRecording("../../testdata/snmprec/juniper-junos.snmprec")
if err != nil {
t.Fatal(err)
}
agent := testutil.StartAgent(t, simagent.Config{Recording: rec, Community: "public"})
root := t.TempDir()
a, err := app.Start(context.Background(), config.Flags{
DataDir: filepath.Join(root, "data"), SecretKeyFile: filepath.Join(root, "keys", "secret.key"), Listen: "[::1]:0",
InsecureHTTP: true, PollInterval: 5 * time.Second, SNMPPort: int(agent.Addr().Port()), TrapListen: "[::1]:0",
})
if errors.Is(err, vmsup.ErrNoBinary) {
t.Skip("built without the vmembed tag")
}
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
ctx, cancel := context.WithTimeout(context.Background(), 40*time.Second)
defer cancel()
if err := a.Stop(ctx); err != nil {
t.Errorf("stop: %v", err)
}
})
base := "http://" + a.Addr()
resp, err := http.Post(base+"/api/v1/devices", "application/json", strings.NewReader(
`{"name":"mx","addresses":["::1"],"snmp":{"version":"v2c","community":"public"}}`))
if err != nil {
t.Fatal(err)
}
body, _ := io.ReadAll(resp.Body)
resp.Body.Close()
var dev struct{ Slug, Profile string }
if resp.StatusCode != 201 || json.Unmarshal(body, &dev) != nil || dev.Profile != "juniper-junos" {
t.Fatalf("add device: %d %s", resp.StatusCode, body)
}
resp, err = http.Post(base+"/api/v1/devices/"+dev.Slug+"/discover", "application/json", nil)
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if resp.StatusCode != 200 || !strings.Contains(string(body), `"interfaces_matched":9`) {
t.Fatalf("discover: %d %s", resp.StatusCode, body)
}
deadline := time.Now().Add(30 * time.Second)
for {
resp, err = http.Get(base + "/api/v1/poller/health")
if err != nil {
t.Fatal(err)
}
body, _ = io.ReadAll(resp.Body)
resp.Body.Close()
if strings.Contains(string(body), `"device":"`+dev.Slug+`"`) && strings.Contains(string(body), `"profile":"juniper-junos"`) {
break
}
if time.Now().After(deadline) {
t.Fatalf("poller health never listed the device: %s", body)
}
time.Sleep(200 * time.Millisecond)
}
}

View File

@@ -0,0 +1,313 @@
package app
import (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/testutil"
)
// fakeMetricStore answers the one thing the supervisor asks of an external
// store, /health, and accepts any write.
func fakeMetricStore(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
_, _ = w.Write([]byte("OK"))
return
}
w.WriteHeader(http.StatusNoContent)
}))
t.Cleanup(srv.Close)
return srv
}
// flagsFor returns flags that start a full server on loopback with external metric stores.
func flagsFor(t *testing.T, vm string) config.Flags {
t.Helper()
return config.Flags{
DataDir: filepath.Join(t.TempDir(), "data"),
SecretKeyFile: filepath.Join(t.TempDir(), "secret.key"),
Listen: "127.0.0.1:0",
InsecureHTTP: true,
VMHotURL: vm,
VMLongURL: vm,
}
}
func stopApp(t *testing.T, a *App) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := a.Stop(ctx); err != nil {
t.Errorf("stop: %v", err)
}
}
func TestStartServesAPIAndStopsCleanly(t *testing.T) {
vm := fakeMetricStore(t)
f := flagsFor(t, vm.URL)
f.TrapListen = "127.0.0.1:0"
a, err := Start(context.Background(), f)
if err != nil {
t.Fatal(err)
}
addr := a.Addr()
resp, err := http.Get("http://" + addr + "/openapi.json")
if err != nil {
t.Fatal(err)
}
_ = resp.Body.Close()
if resp.StatusCode != http.StatusOK {
t.Errorf("GET /openapi.json = %d", resp.StatusCode)
}
if a.HotURL() != vm.URL {
t.Errorf("HotURL = %q, want the external store %q", a.HotURL(), vm.URL)
}
if _, err := os.Stat(f.SecretKeyFile); err != nil {
t.Errorf("first run did not generate the key: %v", err)
}
stopApp(t, a)
stopApp(t, a) // idempotent
if c, err := net.DialTimeout("tcp", addr, time.Second); err == nil {
_ = c.Close()
t.Error("API still accepts connections after Stop")
}
}
func TestStartWithTrapReceiverDisabled(t *testing.T) {
vm := fakeMetricStore(t)
a, err := Start(context.Background(), flagsFor(t, vm.URL))
if err != nil {
t.Fatal(err)
}
defer stopApp(t, a)
if a.traps != nil {
t.Error("an empty --trap-listen must leave the receiver off")
}
}
func TestStartTwiceReusesKeyAndInventory(t *testing.T) {
vm := fakeMetricStore(t)
f := flagsFor(t, vm.URL)
a, err := Start(context.Background(), f)
if err != nil {
t.Fatal(err)
}
stopApp(t, a)
key, _ := os.ReadFile(f.SecretKeyFile)
b, err := Start(context.Background(), f)
if err != nil {
t.Fatalf("second start over the same data dir: %v", err)
}
defer stopApp(t, b)
if again, _ := os.ReadFile(f.SecretKeyFile); !bytes.Equal(key, again) {
t.Error("the key changed between runs")
}
}
func TestStartFailureStopsWhatCameUp(t *testing.T) {
vm := fakeMetricStore(t)
busy, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer func() { _ = busy.Close() }()
f := flagsFor(t, vm.URL)
f.Listen = busy.Addr().String()
a, err := Start(context.Background(), f)
if err == nil || !strings.Contains(err.Error(), "listen") {
if a != nil {
stopApp(t, a)
}
t.Fatalf("error %v, want a listen failure", err)
}
// Start unwound the inventory and metric stores it had brought up, so a second start over the same data dir works.
f.Listen = "127.0.0.1:0"
b, err := Start(context.Background(), f)
if err != nil {
t.Fatalf("restart after a failed start: %v", err)
}
stopApp(t, b)
}
func TestStartRefusesUnusableConfiguration(t *testing.T) {
vm := fakeMetricStore(t)
for name, tc := range map[string]struct {
mut func(*config.Flags)
want string
}{
"postgres needs a dsn": {func(f *config.Flags) { f.DBDriver = "postgres" }, "--db-dsn is required"},
"invalid config.yaml": {func(f *config.Flags) {
if err := os.MkdirAll(f.DataDir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.File(f.DataDir), []byte("retention:\n hot: 1h\n"), 0o600); err != nil {
t.Fatal(err)
}
}, "retention.hot"},
"unreadable key": {func(f *config.Flags) {
if err := os.WriteFile(f.SecretKeyFile, []byte("not a key"), 0o600); err != nil {
t.Fatal(err)
}
}, "secret key"},
} {
t.Run(name, func(t *testing.T) {
f := flagsFor(t, vm.URL)
tc.mut(&f)
a, err := Start(context.Background(), f)
if err == nil {
stopApp(t, a)
t.Fatal("started")
}
if !strings.Contains(err.Error(), tc.want) {
t.Fatalf("error %q, want it to mention %q", err, tc.want)
}
})
}
}
func TestResolveSettingsAppliesDefaults(t *testing.T) {
dir := t.TempDir()
s, err := resolveSettings(config.Flags{DataDir: dir, InsecureHTTP: true})
if err != nil {
t.Fatal(err)
}
if s.Listen != DefaultListen || s.keyFile != DefaultSecretKeyFile || s.MaxInflight <= 0 {
t.Errorf("defaults not applied: %+v", s)
}
if s.driver != "sqlite" || s.dsn != config.InventoryDB(dir) {
t.Errorf("driver %q dsn %q", s.driver, s.dsn)
}
if s.cfg != config.Default() {
t.Errorf("config %+v", s.cfg)
}
}
func TestRunStopsWhenContextEnds(t *testing.T) {
vm := fakeMetricStore(t)
f := flagsFor(t, vm.URL)
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
f.Listen = l.Addr().String()
_ = l.Close() // Run binds it again; Run has no ready signal, so the test polls the port
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
done := make(chan error, 1)
go func() { done <- Run(ctx, f) }()
deadline := time.Now().Add(20 * time.Second)
for {
if c, err := net.Dial("tcp", f.Listen); err == nil {
_ = c.Close()
break
}
if time.Now().After(deadline) {
t.Fatal("Run never started listening")
}
time.Sleep(20 * time.Millisecond)
}
cancel()
select {
case err := <-done:
if err != nil {
t.Fatalf("Run: %v", err)
}
case <-time.After(20 * time.Second):
t.Fatal("Run did not return after its context ended")
}
}
func TestRunReturnsStartError(t *testing.T) {
if err := Run(context.Background(), config.Flags{DataDir: t.TempDir()}); err == nil {
t.Fatal("expected the --insecure-http refusal")
}
}
func TestStopRunsStepsInReverseStartOrderAndJoinsErrors(t *testing.T) {
a := &App{log: testLogger()}
var order []string
for _, n := range []string{"inventory", "metric stores", "poller", "http"} {
a.onStop(n, func(context.Context) error {
order = append(order, n)
if n == "poller" {
return errors.New("boom")
}
return nil
})
}
err := a.Stop(context.Background())
if got := strings.Join(order, ","); got != "http,poller,metric stores,inventory" {
t.Errorf("order %s", got)
}
if err == nil || !strings.Contains(err.Error(), "stop poller: boom") {
t.Errorf("error %v", err)
}
if len(order) != 4 {
t.Errorf("a failing step must not skip the later ones: %v", order)
}
}
func TestStopAfterNothingStartedIsSafe(t *testing.T) {
if err := (&App{log: testLogger()}).Stop(context.Background()); err != nil {
t.Fatal(err)
}
}
func TestLogTrapsDownExplainsPermissionDenied(t *testing.T) {
var buf bytes.Buffer
a := &App{log: slog.New(slog.NewTextHandler(&buf, nil))}
a.logTrapsDown(":162", fmt.Errorf("listen: %w", os.ErrPermission))
if !strings.Contains(buf.String(), "CAP_NET_BIND_SERVICE") {
t.Errorf("no capability hint: %s", buf.String())
}
buf.Reset()
a.logTrapsDown(":162", errors.New("address in use"))
if strings.Contains(buf.String(), "CAP_NET_BIND_SERVICE") || !strings.Contains(buf.String(), "address in use") {
t.Errorf("unexpected: %s", buf.String())
}
}
func TestDiscoverableListsOnlyEnabledNetworkDevicesWithCredentials(t *testing.T) {
ctx := context.Background()
st := testutil.OpenStore(t, clock.Real())
cred := &store.SNMPCredential{Name: "c", Version: "v2c", Community: "public"}
if err := st.CreateCredential(ctx, cred); err != nil {
t.Fatal(err)
}
add := func(d *store.Device) *store.Device {
t.Helper()
if err := st.CreateDevice(ctx, d); err != nil {
t.Fatal(err)
}
return d
}
live := add(&store.Device{Name: "live", Kind: store.KindNetwork, SNMPCredentialID: &cred.ID, Enabled: true, Profile: "generic"})
add(&store.Device{Name: "disabled", Kind: store.KindNetwork, SNMPCredentialID: &cred.ID, Enabled: false, Profile: "generic"})
add(&store.Device{Name: "nocred", Kind: store.KindNetwork, Enabled: true, Profile: "generic"})
add(&store.Device{Name: "check", Kind: store.KindCheckOnly, Enabled: true, Profile: "generic"})
a := &App{st: st, log: testLogger()}
ids, err := a.discoverable(ctx)
if err != nil {
t.Fatal(err)
}
if len(ids) != 1 || ids[0] != live.ID {
t.Fatalf("discoverable = %v, want only %d", ids, live.ID)
}
}

145
internal/app/startup.go Normal file
View File

@@ -0,0 +1,145 @@
package app
import (
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/v10networks/nmon/internal/config"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/secrets"
"github.com/v10networks/nmon/internal/store"
)
// settings are the flags with defaults applied and paths made absolute, plus
// what startup derives from them.
type settings struct {
config.Flags // DataDir is absolute
keyFile string // absolute, outside DataDir
driver string
dsn string
cfg config.Config // <data-dir>/config.yaml over the defaults
}
func (s *settings) profilesDir() string { return config.ProfilesDir(s.DataDir) }
// resolveSettings refuses start-up configurations that would put data at risk
// (no --insecure-http, no data dir, the key inside the data dir) and fills in
// the defaults. It creates the data dir but never the key.
func resolveSettings(f config.Flags) (*settings, error) {
if !f.InsecureHTTP {
return nil, errors.New("TLS and authentication arrive in M6; " +
"until then pass --insecure-http to run without them (development only)")
}
if f.DataDir == "" {
return nil, errors.New("--data-dir is required")
}
applyDefaults(&f)
dataDir, err := filepath.Abs(f.DataDir)
if err != nil {
return nil, err
}
f.DataDir = dataDir
keyFile, err := filepath.Abs(f.SecretKeyFile)
if err != nil {
return nil, err
}
if inside(dataDir, keyFile) {
return nil, fmt.Errorf("--secret-key-file %s is inside --data-dir %s; a backup of the data dir must not contain the key",
keyFile, dataDir)
}
if err := os.MkdirAll(dataDir, 0o755); err != nil {
return nil, err
}
cfg, err := config.Load(dataDir)
if err != nil {
return nil, err
}
s := &settings{Flags: f, keyFile: keyFile, driver: f.DBDriver, dsn: f.DBDSN, cfg: cfg}
if s.driver == "" {
s.driver = store.DriverSQLite
}
if s.dsn == "" {
if s.driver != store.DriverSQLite {
return nil, fmt.Errorf("--db-dsn is required for --db-driver %s", s.driver)
}
s.dsn = config.InventoryDB(dataDir)
}
return s, nil
}
// applyDefaults fills the flags left empty.
func applyDefaults(f *config.Flags) {
if f.Listen == "" {
f.Listen = DefaultListen
}
if f.SecretKeyFile == "" {
f.SecretKeyFile = DefaultSecretKeyFile
}
if f.MaxInflight <= 0 {
f.MaxInflight = poller.DefaultMaxInflight
}
}
// loadKeyring creates the key file on a first run (never next to an existing
// inventory) and loads it.
func loadKeyring(log *slog.Logger, s *settings) (*secrets.Keyring, error) {
inventoryExists := s.driver == store.DriverSQLite && fileExists(config.InventoryDB(s.DataDir))
if err := ensureKey(log, s.keyFile, inventoryExists); err != nil {
return nil, err
}
kr, err := secrets.LoadKeyring(s.keyFile)
if err != nil {
return nil, fmt.Errorf("secret key: %w", err)
}
return kr, nil
}
// ensureKey creates the key file on a first run. It refuses when an inventory
// already exists, since a new key could not decrypt its credentials.
func ensureKey(log *slog.Logger, path string, inventoryExists bool) error {
if _, err := os.Stat(path); err == nil {
return nil
} else if !errors.Is(err, os.ErrNotExist) {
return err
}
if inventoryExists {
return fmt.Errorf("secret key file %s is missing but the inventory exists; "+
"restore the key file (a new key cannot decrypt the stored SNMP credentials)", path)
}
if err := secrets.GenerateKeyFile(path); err != nil {
return fmt.Errorf("generate secret key: %w", err)
}
log.Error("GENERATED A NEW SECRET KEY: back it up now; "+
"without it the SNMP credentials in the inventory cannot be recovered", "path", path)
return nil
}
// inside reports whether path is dir or below it.
func inside(dir, path string) bool {
rel, err := filepath.Rel(resolve(dir), resolve(path))
return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator))
}
// resolve follows symlinks in the deepest existing parent of p.
func resolve(p string) string {
rest := ""
for q := p; ; q = filepath.Dir(q) {
if r, err := filepath.EvalSymlinks(q); err == nil {
return filepath.Join(r, rest)
}
if filepath.Dir(q) == q {
return p
}
rest = filepath.Join(filepath.Base(q), rest)
}
}
func fileExists(p string) bool {
_, err := os.Stat(p)
return err == nil
}

226
internal/app/wiring.go Normal file
View File

@@ -0,0 +1,226 @@
package app
import (
"context"
"errors"
"fmt"
"net"
"net/http"
"os"
"time"
"github.com/v10networks/nmon/internal/api"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/secrets"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/traps"
"github.com/v10networks/nmon/internal/vmsup"
)
// Each start* method brings one subsystem up and registers, with onStop, how
// to shut it down.
// startStore opens and migrates the inventory.
func (a *App) startStore(ctx context.Context, s *settings, kr *secrets.Keyring) error {
st, err := store.Open(ctx, s.driver, s.dsn, kr, clock.Real())
if err != nil {
return fmt.Errorf("open inventory: %w", err)
}
a.st = st
a.onStop("inventory", func(context.Context) error { return st.Close() })
if err := st.Migrate(ctx); err != nil {
return fmt.Errorf("migrate inventory: %w", err)
}
return nil
}
// startMetricStores starts (or connects to) the hot and long metric stores.
func (a *App) startMetricStores(ctx context.Context, s *settings) error {
sup, err := vmsup.New(vmsup.Options{
DataDir: s.DataDir, HotURL: s.VMHotURL, LongURL: s.VMLongURL,
HotRetention: s.cfg.Retention.Hot.D(), LongRetention: s.cfg.Retention.Long.D(), Logger: a.log,
OnEvent: func(e vmsup.Event) {
a.log.Warn("metric store event", "type", e.Type, "store", e.Store, "detail", e.Detail, "restarts", e.Restarts)
},
})
if err != nil {
return err
}
sctx, cancel := context.WithTimeout(ctx, startTimeout)
defer cancel()
if err := sup.Start(sctx); err != nil {
return fmt.Errorf("start metric stores: %w", err) // Start leaves nothing running when it fails
}
a.sup = sup
a.onStop("metric stores", sup.Stop) // flushes both queues
a.log.Info("metric stores up", "hot", sup.HotURL(), "long", sup.LongURL())
return nil
}
// startQueues starts the write queues in front of the metric stores. Stopping
// them waits until both have drained.
func (a *App) startQueues() {
bg, cancel := context.WithCancel(context.Background())
a.hot = vmsup.NewQueue(vmsup.StoreHot, a.sup, vmsup.QueueOptions{})
a.long = vmsup.NewQueue(vmsup.StoreLong, a.sup, vmsup.QueueOptions{})
drained := make(chan struct{}, 2)
for _, q := range []*vmsup.Queue{a.hot, a.long} {
go func() { q.Run(bg); drained <- struct{}{} }()
}
allDrained := make(chan struct{})
go func() { <-drained; <-drained; close(allDrained) }()
a.onStop("write queues", func(context.Context) error {
cancel()
<-allDrained
return nil
})
}
// loadProfiles loads the shipped profiles plus the operator's directory.
func (a *App) loadProfiles(s *settings) error {
set, err := profile.Load(s.profilesDir())
if err != nil {
return fmt.Errorf("load profiles: %w", err)
}
a.profiles = set
a.log.Info("profiles loaded", "count", len(set.Names()), "operator_dir", s.profilesDir())
return nil
}
// startPoller starts the SNMP poller; it reloads profiles from the same directory on demand.
func (a *App) startPoller(s *settings) {
dir := s.profilesDir()
a.poller = poller.New(poller.Options{
Store: a.st, Writer: a.hot, Interval: s.PollInterval, MaxInflight: s.MaxInflight, SNMPPort: s.SNMPPort, Logger: a.log,
Profiles: a.profiles,
LoadProfiles: func() (*profile.Set, error) { return profile.Load(dir) },
})
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() { _ = a.poller.Run(ctx); close(done) }()
a.onStop("poller", func(context.Context) error {
cancel()
<-done
return nil
})
}
// newScheduler builds the discovery scheduler: every 12 h per device
// (jittered), on demand and on cold start. startScheduler runs it.
func (a *App) newScheduler(s *settings) {
deps := discovery.Deps{Store: a.st, Profiles: a.profiles, SNMPPort: s.SNMPPort, Logger: a.log}
a.sched = discovery.NewScheduler(discovery.SchedulerConfig{
Devices: a.discoverable,
Run: deps.DiscoverID,
OnDone: a.afterDiscover,
Workers: discovery.DefaultWorkers,
Logger: a.log,
})
}
func (a *App) startScheduler() {
ctx, cancel := context.WithCancel(context.Background())
wg := a.sched.Start(ctx)
a.onStop("discovery scheduler", func(context.Context) error {
cancel()
wg.Wait()
return nil
})
}
// newTraps builds the trap receiver, which can ask the scheduler for a
// discovery. It is built before the scheduler starts so afterDiscover never
// sees it half-assigned. A disabled receiver leaves a.traps nil.
func (a *App) newTraps(s *settings) {
if s.TrapListen == "" {
return
}
a.traps = traps.New(traps.Options{
Store: a.st, Addr: s.TrapListen, PollNow: a.poller.PollNow, Discover: a.sched.Trigger, Logger: a.log,
})
}
// startTraps runs the trap receiver. A receiver that cannot bind never stops
// the monitor: it logs why and polling and discovery carry on.
func (a *App) startTraps(s *settings) {
if a.traps == nil {
return
}
ctx, cancel := context.WithCancel(context.Background())
done := make(chan struct{})
go func() {
defer close(done)
if err := a.traps.Run(ctx); err != nil && ctx.Err() == nil {
a.logTrapsDown(s.TrapListen, err)
}
}()
a.onStop("trap receiver", func(context.Context) error {
cancel()
<-done
return nil
})
}
func (a *App) logTrapsDown(addr string, err error) {
if errors.Is(err, os.ErrPermission) {
a.log.Warn("trap receiver not started: permission denied binding "+addr+"; "+
"grant CAP_NET_BIND_SERVICE (setcap 'cap_net_bind_service=+ep' on the nmon binary, or AmbientCapabilities in the unit) "+
"or pass --trap-listen with a port above 1023; polling and discovery are unaffected", "err", err)
return
}
a.log.Warn("trap receiver not started; polling and discovery are unaffected", "addr", addr, "err", err)
}
// startAPI binds the listen address and serves the HTTP API.
func (a *App) startAPI(s *settings) error {
handler := api.New(api.Deps{
Store: a.st, Querier: vmsup.NewQuerier(a.sup), Poller: a.poller, SNMPPort: s.SNMPPort, Logger: a.log,
Profiles: a.profiles, PollerStatus: a.poller.Status, AfterDiscover: a.afterDiscover,
})
ln, err := net.Listen("tcp", s.Listen)
if err != nil {
return fmt.Errorf("listen: %w", err)
}
a.ln = ln
a.srv = &http.Server{Handler: handler, ReadHeaderTimeout: 10 * time.Second, WriteTimeout: 2 * time.Minute, IdleTimeout: 2 * time.Minute}
go func() {
if err := a.srv.Serve(ln); !errors.Is(err, http.ErrServerClosed) {
a.serveErr <- err
}
}()
a.onStop("http", a.srv.Shutdown)
a.log.Warn("serving plain HTTP without authentication (--insecure-http); do not expose this port", "addr", ln.Addr().String())
return nil
}
// discoverable lists the devices the scheduler should discover.
func (a *App) discoverable(ctx context.Context) ([]int64, error) {
var ids []int64
for off := int64(0); ; {
page, total, err := a.st.ListDevices(ctx, store.DeviceFilter{Kind: store.KindNetwork},
store.Page{Offset: off, Limit: store.MaxLimit})
if err != nil {
return nil, err
}
for _, d := range page {
if d.Enabled && d.RetiredAt == nil && d.PurgedAt == nil && d.SNMPCredentialID != nil {
ids = append(ids, d.ID)
}
}
off += int64(len(page))
if len(page) == 0 || off >= total {
return ids, nil
}
}
}
// afterDiscover tells the poller and the trap receiver that a device changed.
func (a *App) afterDiscover(id int64) {
a.poller.RefreshDevice(id)
if a.traps != nil {
a.traps.Refresh()
}
}

45
internal/cliout/cliout.go Normal file
View File

@@ -0,0 +1,45 @@
// Package cliout writes command output and remembers the first failure, so a
// run of prints needs one error check at the end instead of one per line.
package cliout
import (
"fmt"
"io"
)
// Writer wraps an io.Writer. After the first write error every later write is
// skipped, and Err reports that error. A CLI returns Err so a closed pipe or a
// full disk becomes exit code 1 instead of silently truncated output.
type Writer struct {
w io.Writer
err error
}
// New returns a Writer that prints to w.
func New(w io.Writer) *Writer { return &Writer{w: w} }
// Printf formats like fmt.Fprintf.
func (w *Writer) Printf(format string, a ...any) {
if w.err == nil {
_, w.err = fmt.Fprintf(w.w, format, a...)
}
}
// Println formats like fmt.Fprintln.
func (w *Writer) Println(a ...any) {
if w.err == nil {
_, w.err = fmt.Fprintln(w.w, a...)
}
}
// Write makes Writer an io.Writer, for example for a tabwriter. It reports
// success after a failure so callers keep going; check Err once at the end.
func (w *Writer) Write(p []byte) (int, error) {
if w.err == nil {
_, w.err = w.w.Write(p)
}
return len(p), nil
}
// Err returns the first write error, or nil.
func (w *Writer) Err() error { return w.err }

View File

@@ -0,0 +1,51 @@
package cliout
import (
"bytes"
"errors"
"strings"
"testing"
)
type failAfter struct {
n int
buf bytes.Buffer
}
var errFull = errors.New("disk full")
func (f *failAfter) Write(p []byte) (int, error) {
if f.n <= 0 {
return 0, errFull
}
f.n--
return f.buf.Write(p)
}
func TestWriterPrintsAndReportsNoError(t *testing.T) {
var b bytes.Buffer
w := New(&b)
w.Printf("a=%d\n", 1)
w.Println("b", 2)
if _, err := w.Write([]byte("c\n")); err != nil {
t.Fatal(err)
}
if w.Err() != nil || b.String() != "a=1\nb 2\nc\n" {
t.Fatalf("err=%v out=%q", w.Err(), b.String())
}
}
func TestWriterKeepsFirstErrorAndStopsWriting(t *testing.T) {
f := &failAfter{n: 1}
w := New(f)
w.Println("kept")
w.Printf("lost %s\n", "x")
w.Println("lost")
_, _ = w.Write([]byte("lost"))
if !errors.Is(w.Err(), errFull) {
t.Fatalf("err = %v", w.Err())
}
if got := f.buf.String(); !strings.HasPrefix(got, "kept") || strings.Contains(got, "lost") {
t.Fatalf("out = %q", got)
}
}

View File

@@ -66,12 +66,14 @@ type waiter struct {
// NewFake returns a Fake clock starting at start.
func NewFake(start time.Time) *Fake { return &Fake{now: start} }
// Now returns the fake time.
func (f *Fake) Now() time.Time {
f.mu.Lock()
defer f.mu.Unlock()
return f.now
}
// After returns a channel that fires once Advance moves the fake time past d.
func (f *Fake) After(d time.Duration) <-chan time.Time {
f.mu.Lock()
defer f.mu.Unlock()
@@ -84,6 +86,7 @@ func (f *Fake) After(d time.Duration) <-chan time.Time {
return w.ch
}
// NewTicker returns a ticker that fires each time Advance crosses a multiple of d.
func (f *Fake) NewTicker(d time.Duration) Ticker {
if d <= 0 {
panic("clock: non-positive ticker interval")
@@ -95,6 +98,7 @@ func (f *Fake) NewTicker(d time.Duration) Ticker {
return &fakeTicker{f: f, w: w}
}
// Sleep blocks until Advance moves the fake time past d, or ctx is done.
func (f *Fake) Sleep(ctx context.Context, d time.Duration) error {
select {
case <-ctx.Done():

View File

@@ -24,6 +24,7 @@ type Flags struct {
VMHotURL string // empty: run embedded child
VMLongURL string
MaxInflight int
TrapListen string // UDP address for SNMP traps; empty disables the receiver
PollInterval time.Duration // dev/test override of the 60 s interface interval; 0 = profile default
SNMPPort int // dev/test override of UDP 161 for every device; 0 = 161
}
@@ -41,14 +42,26 @@ type Retention struct {
Long Duration `yaml:"long"` // hourly rates, default 2y, max 10y
}
// Paths derived from the data dir.
// InventoryDB is the SQLite inventory file inside the data dir.
func InventoryDB(dataDir string) string { return filepath.Join(dataDir, "inventory.db") }
func HotDir(dataDir string) string { return filepath.Join(dataDir, "vm-hot") }
func LongDir(dataDir string) string { return filepath.Join(dataDir, "vm-long") }
func BinDir(dataDir string) string { return filepath.Join(dataDir, "bin") }
// HotDir is the data directory of the hot metric store.
func HotDir(dataDir string) string { return filepath.Join(dataDir, "vm-hot") }
// LongDir is the data directory of the long-retention metric store.
func LongDir(dataDir string) string { return filepath.Join(dataDir, "vm-long") }
// BinDir is where the embedded metric-store binaries are unpacked.
func BinDir(dataDir string) string { return filepath.Join(dataDir, "bin") }
// ProfilesDir holds operator-supplied device profiles that override the shipped ones.
func ProfilesDir(dataDir string) string { return filepath.Join(dataDir, "profiles") }
func MIBsDir(dataDir string) string { return filepath.Join(dataDir, "mibs") }
func File(dataDir string) string { return filepath.Join(dataDir, "config.yaml") }
// MIBsDir holds operator-supplied MIB files.
func MIBsDir(dataDir string) string { return filepath.Join(dataDir, "mibs") }
// File is the path of config.yaml inside the data dir.
func File(dataDir string) string { return filepath.Join(dataDir, "config.yaml") }
const (
day = 24 * time.Hour
@@ -110,32 +123,46 @@ func Save(dataDir string, c Config) error {
if err != nil {
return err
}
f, err := os.CreateTemp(dataDir, ".config-*.yaml")
return writeFileAtomic(File(dataDir), b)
}
// writeFileAtomic writes b to a temp file beside path, syncs it and renames it
// over path, so readers never observe a partial file.
func writeFileAtomic(path string, b []byte) (err error) {
f, err := os.CreateTemp(filepath.Dir(path), ".config-*.yaml")
if err != nil {
return err
}
defer os.Remove(f.Name())
if _, err := f.Write(b); err != nil {
f.Close()
tmp := f.Name()
defer func() {
if err != nil {
_ = os.Remove(tmp) // best-effort: the write already failed
}
}()
if _, err = f.Write(b); err != nil {
_ = f.Close() // best-effort: the write error is the one to report
return err
}
if err := f.Sync(); err != nil {
f.Close()
if err = f.Sync(); err != nil {
_ = f.Close() // best-effort: the sync error is the one to report
return err
}
if err := f.Close(); err != nil {
if err = f.Close(); err != nil {
return err
}
return os.Rename(f.Name(), File(dataDir))
return os.Rename(tmp, path)
}
// Duration is a time.Duration that also accepts d (days) and y (years) units in YAML.
type Duration time.Duration
// D converts to the standard library type.
func (d Duration) D() time.Duration { return time.Duration(d) }
// MarshalYAML writes the compact form produced by FormatDuration.
func (d Duration) MarshalYAML() (any, error) { return FormatDuration(time.Duration(d)), nil }
// UnmarshalYAML accepts anything ParseDuration does.
func (d *Duration) UnmarshalYAML(n *yaml.Node) error {
v, err := ParseDuration(n.Value)
if err != nil {

View File

@@ -0,0 +1,161 @@
package config
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"gopkg.in/yaml.v3"
)
func TestLoadMissingFileYieldsDefaults(t *testing.T) {
c, err := Load(t.TempDir())
if err != nil {
t.Fatal(err)
}
if c != Default() {
t.Fatalf("got %+v want defaults %+v", c, Default())
}
}
func TestSaveThenLoadRoundTripsAndLeavesNoTempFiles(t *testing.T) {
dir := t.TempDir()
c := Default()
c.Retention.Hot = Duration(45 * day)
c.Retention.FiveMinute = Duration(200 * day)
c.Timezone = "America/Toronto"
if err := Save(dir, c); err != nil {
t.Fatal(err)
}
got, err := Load(dir)
if err != nil {
t.Fatal(err)
}
if got != c {
t.Fatalf("got %+v want %+v", got, c)
}
ents, _ := os.ReadDir(dir)
if len(ents) != 1 || ents[0].Name() != "config.yaml" {
t.Fatalf("expected only config.yaml, got %v", ents)
}
}
func TestSaveRejectsInvalidConfigAndKeepsOldFile(t *testing.T) {
dir := t.TempDir()
if err := Save(dir, Default()); err != nil {
t.Fatal(err)
}
before, _ := os.ReadFile(File(dir))
bad := Default()
bad.Retention.Hot = Duration(time.Hour)
if err := Save(dir, bad); err == nil {
t.Fatal("expected validation error")
}
after, _ := os.ReadFile(File(dir))
if string(before) != string(after) {
t.Fatal("invalid save modified config.yaml")
}
}
func TestSaveFailsWhenDataDirMissing(t *testing.T) {
if err := Save(filepath.Join(t.TempDir(), "nope"), Default()); err == nil {
t.Fatal("expected error")
}
}
func TestLoadRejectsMalformedAndInvalidFiles(t *testing.T) {
for name, body := range map[string]string{
"malformed": "retention: [",
"short hot": "retention:\n hot: 1h\n",
"bad tz": "timezone: Not/AZone\n",
"long > 10y": "retention:\n long: 11y\n",
} {
dir := t.TempDir()
if err := os.WriteFile(File(dir), []byte(body), 0o600); err != nil {
t.Fatal(err)
}
if _, err := Load(dir); err == nil {
t.Errorf("%s: expected error", name)
}
}
}
func TestLoadUnreadableFileIsAnError(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(File(dir), 0o755); err != nil { // a directory where the file should be
t.Fatal(err)
}
if _, err := Load(dir); err == nil {
t.Fatal("expected error")
}
}
func TestValidate(t *testing.T) {
cases := []struct {
name string
mut func(*Config)
want string
}{
{"defaults ok", func(*Config) {}, ""},
{"hot under a day", func(c *Config) { c.Retention.Hot = Duration(time.Hour) }, "retention.hot"},
{"long over ten years", func(c *Config) { c.Retention.Long = Duration(11 * year) }, "retention.long"},
{"fiveminute under hot", func(c *Config) { c.Retention.FiveMinute = Duration(day) }, "retention.fiveminute"},
{"unknown timezone", func(c *Config) { c.Timezone = "Mars/Olympus" }, "timezone"},
}
for _, tc := range cases {
c := Default()
tc.mut(&c)
err := c.Validate()
switch {
case tc.want == "" && err != nil:
t.Errorf("%s: unexpected %v", tc.name, err)
case tc.want != "" && (err == nil || !strings.Contains(err.Error(), tc.want)):
t.Errorf("%s: got %v want mention of %q", tc.name, err, tc.want)
}
}
}
func TestParseAndFormatDurationRoundTrip(t *testing.T) {
for _, d := range []time.Duration{30 * day, 2 * year, 10 * year, day, 36 * time.Hour, 90 * time.Minute, 0} {
s := FormatDuration(d)
got, err := ParseDuration(s)
if err != nil || got != d {
t.Errorf("%v -> %q -> %v (%v)", d, s, got, err)
}
}
if FormatDuration(2*year) != "2y" || FormatDuration(30*day) != "30d" || FormatDuration(36*time.Hour) != "36h0m0s" {
t.Fatal("unexpected compact forms")
}
}
func TestParseDurationErrors(t *testing.T) {
for _, s := range []string{"", "d", "xd", "abc"} {
if _, err := ParseDuration(s); err == nil {
t.Errorf("%q: expected error", s)
}
}
}
func TestDurationYAML(t *testing.T) {
b, err := yaml.Marshal(struct{ D Duration }{Duration(30 * day)})
if err != nil || !strings.Contains(string(b), "30d") {
t.Fatalf("marshal: %q %v", b, err)
}
var out struct{ D Duration }
if err := yaml.Unmarshal([]byte("d: 2y"), &out); err != nil || out.D.D() != 2*year {
t.Fatalf("unmarshal: %v %v", out, err)
}
if err := yaml.Unmarshal([]byte("d: nonsense"), &out); err == nil {
t.Fatal("expected error")
}
}
func TestPathsLiveUnderDataDir(t *testing.T) {
for _, p := range []string{InventoryDB("/d"), HotDir("/d"), LongDir("/d"), BinDir("/d"), ProfilesDir("/d"), MIBsDir("/d"), File("/d")} {
if filepath.Dir(p) != "/d" {
t.Errorf("%s not directly under data dir", p)
}
}
}

249
internal/discovery/apply.go Normal file
View File

@@ -0,0 +1,249 @@
package discovery
import (
"context"
"encoding/json"
"slices"
"strings"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/ulid"
)
// Actor is the audit_log actor of every decision discovery makes.
const Actor = "discovery"
// WarningFast32 is set while an included interface counts octets in 32 bits on a fast link.
const WarningFast32 = "counter_32bit_fast_link"
// WarningProfileMissing prefixes the persistent warning for a missing profile: "profile_missing:<name>".
const WarningProfileMissing = "profile_missing"
const fastLinkBps = 100_000_000
// Apply writes a Collect result for dev in one transaction: sys fields, profile,
// interface rows (identity matching, see Match), the chassis serial, component
// tables, warnings, audit rows, identity reviews and the discoveries row. dev is
// refreshed from the stored row.
func Apply(ctx context.Context, st store.Store, dev *store.Device, res *Result, clk clock.Clock) (*Report, error) {
var rep *Report
err := st.WithTx(ctx, func(tx store.Store) error {
rep = &Report{}
return apply(ctx, tx, dev, res, clk, rep)
})
if err != nil {
return nil, err
}
return rep, nil
}
// applier carries the state of one discovery write: the stored device row,
// the Collect result, the interface match and the report being filled in.
// Each method is one step of the write, in the order apply runs them.
type applier struct {
tx store.Store
cur *store.Device
res *Result
clk clock.Clock
rep *Report
mr MatchResult
openKeys map[string]bool // candidate-set keys of reviews that are already open
fast32 bool // an included interface counts octets in 32 bits on a fast link
partial bool // some table was incomplete
}
// apply is the whole discovery write: load current state, match and write
// interfaces, audit identity decisions, open reviews, check for an RMA,
// update the device row and warnings, write components and record the run.
func apply(ctx context.Context, tx store.Store, dev *store.Device, res *Result, clk clock.Clock, rep *Report) error {
a := &applier{tx: tx, res: res, clk: clk, rep: rep}
steps := []func(context.Context) error{
func(ctx context.Context) error { return a.load(ctx, dev.ID) },
a.writeInterfaces,
a.retireInterfaces,
a.auditIdentityDecisions,
a.openReviews,
a.checkHardwareReplaced,
a.updateDevice,
a.updateWarnings,
a.writeComponents,
a.recordDiscovery,
}
for _, step := range steps {
if err := step(ctx); err != nil {
return err
}
}
*dev = *a.cur
return nil
}
// load reads the stored device row and runs identity matching against its interfaces.
// UpdateDevice writes every mutable column, so we start from the stored row
// rather than a caller's copy that may predate SetAddresses.
func (a *applier) load(ctx context.Context, deviceID int64) error {
cur, err := a.tx.GetDeviceByID(ctx, deviceID)
if err != nil {
return err
}
a.cur = cur
existing, err := a.tx.ListInterfaces(ctx, cur.ID, true)
if err != nil {
return err
}
frozen, openKeys, err := openAmbiguous(ctx, a.tx, cur.ID)
if err != nil {
return err
}
a.openKeys = openKeys
now := a.clk.Now()
// An overrun interface table is incomplete (columns after the overrun were
// never walked), so it proves nothing: leave every interface row alone.
if !a.res.IfacePartial {
a.mr = Match(existing, a.res.Ifaces, MatchOptions{Frozen: frozen, NewID: func() string { return ulid.New(now) }})
}
return nil
}
// checkHardwareReplaced handles the chassis serial: the first serial is
// recorded silently; a change with otherwise unchanged interfaces is an RMA
// and opens a hardware_replaced review.
func (a *applier) checkHardwareReplaced(ctx context.Context) error {
cur, res, rep, mr := a.cur, a.res, a.rep, a.mr
if res.HWSerial == "" || res.HWSerial == cur.HWSerial {
return nil
}
if cur.HWSerial == "" {
cur.HWSerial = res.HWSerial
return nil
}
clean := !res.IfacePartial && len(mr.Groups) == 0 && len(mr.Retire) == 0 && rep.Matched+rep.Unretired > 0
if !clean {
return nil
}
matched := rep.Matched + rep.Unretired
r, err := a.tx.CreateIdentityReview(ctx, &cur.ID, store.ReviewHardwareReplaced,
[]map[string]string{{"serial": cur.HWSerial}, {"serial": res.HWSerial}},
map[string]any{"old_serial": cur.HWSerial, "new_serial": res.HWSerial, "interfaces_matched": matched})
if err != nil {
return err
}
rep.Reviews = append(rep.Reviews, r.ID)
rep.HardwareReplaced = true
err = a.tx.AppendAudit(ctx, Actor, "device.hardware_replaced", cur.Slug,
map[string]any{"hw_serial": cur.HWSerial}, map[string]any{"hw_serial": res.HWSerial, "interfaces_matched": matched})
if err != nil {
return err
}
cur.HWSerial = res.HWSerial
return nil
}
// updateDevice writes the sys fields and profile, auditing a profile change.
func (a *applier) updateDevice(ctx context.Context) error {
cur, res := a.cur, a.res
oldProfile := cur.Profile
cur.SysDescr, cur.SysObjectID, cur.SysName = res.SysDescr, res.SysObjectID, res.SysName
if res.Profile != nil && res.MissingProfile == "" {
cur.Profile = res.Profile.Name
}
if cur.Profile == "" {
cur.Profile = "generic"
}
if err := a.tx.UpdateDevice(ctx, cur); err != nil {
return err
}
if cur.Profile != oldProfile {
err := a.tx.AppendAudit(ctx, Actor, "device.profile", cur.Slug,
map[string]any{"profile": oldProfile}, map[string]any{"profile": cur.Profile})
if err != nil {
return err
}
}
a.rep.Profile, a.rep.ProfileMissing = cur.Profile, res.MissingProfile
return nil
}
// isDiscoveryWarning reports whether w is one of the warnings discovery owns.
func isDiscoveryWarning(w string) bool {
return w == WarningFast32 || strings.HasPrefix(w, WarningProfileMissing+":")
}
// desiredWarnings is the device's warning set after this run: warnings owned
// by other components are kept, discovery's own are recomputed.
func (a *applier) desiredWarnings() []string {
var warns []string
for _, w := range a.cur.Warnings {
if w == WarningFast32 || (a.res.Profile != nil && strings.HasPrefix(w, WarningProfileMissing+":")) {
continue
}
warns = append(warns, w)
}
if a.fast32 {
warns = append(warns, WarningFast32)
}
if a.res.MissingProfile != "" {
warns = append(warns, WarningProfileMissing+":"+a.res.MissingProfile)
}
slices.Sort(warns)
return warns
}
// updateWarnings persists the warnings discovery owns when they changed and
// reports the current ones.
func (a *applier) updateWarnings(ctx context.Context) error {
cur := a.cur
warns := a.desiredWarnings()
if !slices.Equal(warns, slices.Sorted(slices.Values(cur.Warnings))) {
state := cur.PollState
if state == "" {
state = store.PollOK
}
if err := a.tx.SetPollState(ctx, cur.ID, state, warns); err != nil {
return err
}
cur.Warnings = warns
}
for _, w := range cur.Warnings {
if isDiscoveryWarning(w) {
a.rep.Warnings = append(a.rep.Warnings, w)
}
}
return nil
}
// writeComponents replaces the component rows of every table that was read
// completely and builds the per-table report.
func (a *applier) writeComponents(ctx context.Context) error {
for _, tr := range a.res.Tables {
r := TableReport{Name: tr.Name, Status: tr.Status, Rows: len(tr.Rows), Detail: tr.Detail}
if tr.Name == "interfaces" {
r.Rows = len(a.res.Ifaces)
}
if tr.Components && tr.Status == StatusOK {
if err := a.tx.ReplaceComponents(ctx, a.cur.ID, tr.Name, tr.Rows); err != nil {
return err
}
}
if tr.Status == StatusPartial || tr.Status == StatusError {
a.partial = true
}
a.rep.Tables = append(a.rep.Tables, r)
}
a.rep.Partial = a.partial
return nil
}
// recordDiscovery writes the discoveries row summarising the per-table outcome.
func (a *applier) recordDiscovery(ctx context.Context) error {
detail := make(map[string]TableReport, len(a.rep.Tables))
for _, r := range a.rep.Tables {
detail[r.Name] = r
}
tables, _ := json.Marshal(detail)
return a.tx.RecordDiscovery(ctx, &store.Discovery{
DeviceID: a.cur.ID, StartedAt: a.res.StartedAt, FinishedAt: a.res.FinishedAt,
Partial: a.partial, TablesJSON: string(tables),
})
}

View File

@@ -0,0 +1,200 @@
package discovery
import (
"context"
"encoding/json"
"sort"
"strings"
"github.com/v10networks/nmon/internal/store"
)
func ifaceEvidence(slug string, idx int64, name, descr, mac string) map[string]any {
return map[string]any{"device": slug, "ifindex": idx, "ifname": name, "ifdescr": descr, "mac": mac}
}
// interfaceRow builds the row to upsert for one match outcome, starting from
// the stored row when the interface already exists.
func (a *applier) interfaceRow(oc Outcome) store.Interface {
f := a.res.Ifaces[oc.Found]
row := store.Interface{ID: oc.ID}
if oc.Existing != nil {
row = *oc.Existing
}
row.IfIndex, row.IfName, row.IfDescr, row.IfAlias = f.IfIndex, f.Name, f.Descr, f.Alias
row.IfPhysAddr, row.IfSpeed, row.IfHighSpeed, row.IfType = f.PhysAddr, f.Speed, f.HighSpeed, f.Type
row.AdminStatus, row.OperStatus, row.HasHCCounters = f.Admin, f.Oper, f.HasHC
row.RetiredAt = nil
row.Excluded = a.res.Profile != nil && a.res.Profile.Excluded(f.Name)
row.Unreliable = !f.HasHC && effectiveBps(f) >= fastLinkBps
return row
}
// writeInterfaces upserts one row per found interface and notes whether an
// included one counts in 32 bits on a fast link.
func (a *applier) writeInterfaces(ctx context.Context) error {
rows := make([]store.Interface, 0, len(a.mr.Outcomes))
for _, oc := range a.mr.Outcomes {
row := a.interfaceRow(oc)
if row.Unreliable && !row.Excluded {
a.fast32 = true
}
rows = append(rows, row)
}
return a.tx.UpsertInterfaces(ctx, a.cur.ID, rows)
}
// retireInterfaces retires the stored interfaces that were not found again and audits each.
func (a *applier) retireInterfaces(ctx context.Context) error {
for _, e := range a.mr.Retire {
if err := a.tx.RetireInterface(ctx, e.ID); err != nil {
return err
}
a.rep.Retired++
err := a.tx.AppendAudit(ctx, Actor, "interface.retired", e.ID,
ifaceEvidence(a.cur.Slug, e.IfIndex, e.IfName, e.IfDescr, e.IfPhysAddr), nil)
if err != nil {
return err
}
}
return nil
}
// auditIdentityDecisions audits every identity decision. A clean match that
// changed nothing identifying is not a decision worth a row every 12 hours.
func (a *applier) auditIdentityDecisions(ctx context.Context) error {
for _, oc := range a.mr.Outcomes {
before, after, worthy := a.decisionEvidence(oc)
if !worthy {
continue
}
if err := a.tx.AppendAudit(ctx, Actor, "interface."+oc.Action, oc.ID, before, after); err != nil {
return err
}
}
return nil
}
// decisionEvidence counts the outcome in the report and returns the audit
// before/after evidence; worthy is false for a no-op match.
func (a *applier) decisionEvidence(oc Outcome) (before, after map[string]any, worthy bool) {
f := a.res.Ifaces[oc.Found]
slug := a.cur.Slug
after = ifaceEvidence(slug, f.IfIndex, f.Name, f.Descr, f.PhysAddr)
switch oc.Action {
case ActionNew:
a.rep.New++
case ActionSplit:
a.rep.Split++
g := a.mr.Groups[oc.Group]
ids := make([]string, 0, len(g.Candidates))
for _, c := range g.Candidates {
ids = append(ids, c.ID)
}
after["review_group"] = oc.Group
after["candidates"] = ids
after["reasons"] = g.Reasons
case ActionMatched, ActionUnretired:
e := oc.Existing
before = ifaceEvidence(slug, e.IfIndex, e.IfName, e.IfDescr, e.IfPhysAddr)
if oc.Action == ActionUnretired {
a.rep.Unretired++
break
}
a.rep.Matched++
if e.IfIndex == f.IfIndex && e.IfName == f.Name && e.IfDescr == f.Descr && e.IfPhysAddr == f.PhysAddr {
return nil, nil, false
}
}
return before, after, true
}
// openReviews opens an identity review for each ambiguous group that has no
// open review over the same candidate set.
func (a *applier) openReviews(ctx context.Context) error {
for gi, g := range a.mr.Groups {
cands, key := reviewCandidates(g)
if a.openKeys[key] {
continue
}
r, err := a.tx.CreateIdentityReview(ctx, &a.cur.ID, store.ReviewAmbiguousMatch, cands,
map[string]any{"key": key, "reasons": g.Reasons, "found": a.reviewFound(gi, g)})
if err != nil {
return err
}
a.openKeys[key] = true
a.rep.Reviews = append(a.rep.Reviews, r.ID)
}
return nil
}
// reviewCandidates describes a group's candidates and derives the key that
// identifies the candidate set (sorted IDs, comma-joined).
func reviewCandidates(g Group) (cands []map[string]any, key string) {
cands = make([]map[string]any, 0, len(g.Candidates))
ids := make([]string, 0, len(g.Candidates))
for _, c := range g.Candidates {
ids = append(ids, c.ID)
cands = append(cands, map[string]any{"id": c.ID, "ifindex": c.IfIndex, "ifname": c.IfName,
"ifdescr": c.IfDescr, "mac": c.IfPhysAddr, "retired": c.RetiredAt != nil})
}
sort.Strings(ids)
return cands, strings.Join(ids, ",")
}
// reviewFound describes the found interfaces of group gi, with the ID each was given.
func (a *applier) reviewFound(gi int, g Group) []map[string]any {
var found []map[string]any
for _, fi := range g.Found {
f := a.res.Ifaces[fi]
m := ifaceEvidence(a.cur.Slug, f.IfIndex, f.Name, f.Descr, f.PhysAddr)
for _, oc := range a.mr.Outcomes {
if oc.Found == fi && oc.Group == gi {
m["new_id"] = oc.ID
}
}
found = append(found, m)
}
return found
}
// effectiveBps is the link speed in bit/s; ifSpeed saturates at 4294967295, then ifHighSpeed (Mbit/s) rules.
func effectiveBps(f Iface) int64 {
if f.Speed == 1<<32-1 {
return f.HighSpeed * 1_000_000
}
return f.Speed
}
// openAmbiguous returns the candidate IDs of the device's open ambiguous_match
// reviews (frozen rows) and their keys (to avoid opening the same review twice).
func openAmbiguous(ctx context.Context, tx store.Store, deviceID int64) (map[string]bool, map[string]bool, error) {
frozen, keys := map[string]bool{}, map[string]bool{}
for off := int64(0); ; off += store.MaxLimit {
page, total, err := tx.ListOpenIdentityReviews(ctx, store.Page{Offset: off, Limit: store.MaxLimit})
if err != nil {
return nil, nil, err
}
for _, r := range page {
if r.Kind != store.ReviewAmbiguousMatch || r.DeviceID == nil || *r.DeviceID != deviceID {
continue
}
var cands []struct {
ID string `json:"id"`
}
if json.Unmarshal([]byte(r.Candidates), &cands) != nil {
continue
}
ids := make([]string, 0, len(cands))
for _, c := range cands {
frozen[c.ID] = true
ids = append(ids, c.ID)
}
sort.Strings(ids)
keys[strings.Join(ids, ",")] = true
}
if off+store.MaxLimit >= total {
return frozen, keys, nil
}
}
}

View File

@@ -0,0 +1,424 @@
package discovery
import (
"cmp"
"context"
"encoding/hex"
"errors"
"fmt"
"slices"
"strconv"
"strings"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/snmp"
"github.com/v10networks/nmon/internal/store"
)
// Iface is one interface as the device reports it.
type Iface struct {
IfIndex int64
Name, Descr string
Alias string
PhysAddr string // colon-separated lower-case hex
Speed int64
HighSpeed int64
Type int64
Admin, Oper int64
HasHC bool
}
// Options tune Collect.
type Options struct {
Profiles *profile.Set // nil: no profile, default limits, no discovery tables
// Current is the device's stored profile name. A name other than ""
// and "generic" is an explicit assignment: it is kept while the profile
// exists, and falls back to generic with a profile_missing warning while it
// does not. "" or "generic" is matched afresh from sysObjectID and sysDescr.
Current string
}
// Result is what Collect learned.
type Result struct {
SysDescr, SysObjectID, SysName string
SysUpTime time.Duration
Ifaces []Iface
// IfacePartial is set when the interface tables overran their limit; the
// rows are incomplete, so Apply retires nothing.
IfacePartial bool
HWSerial string // chassis serial from ENTITY-MIB, "" if none reported
// Profile is the profile in effect, nil when Options.Profiles was nil or empty.
Profile *profile.Resolved
// MissingProfile is the explicit profile that no longer exists; Profile is generic then.
MissingProfile string
Tables []TableResult
StartedAt, FinishedAt time.Time
}
// TableResult is one walked table. Rows is only meaningful when Status is ok;
// anything else keeps the previous rows.
type TableResult struct {
Name string
Status string
Rows []store.Component // Idx and Label
Detail string
// Components is set for profile tables, whose rows go to device_components.
Components bool
}
var errRowLimit = errors.New("row limit exceeded")
// walker walks columns of one table under a shared row limit and deadline.
type walker struct {
sess *snmp.Session
ctx context.Context
maxRep int
limit int
seen map[string]struct{}
}
// column walks root and calls fn with the instance suffix of every varbind.
func (w *walker) column(root string, fn func(idx string, vb snmp.Varbind)) error {
prefix := root + "."
return w.sess.Walk(w.ctx, root, w.maxRep, func(vb snmp.Varbind) error {
idx := strings.TrimPrefix(vb.OID, prefix)
if _, ok := w.seen[idx]; !ok {
w.seen[idx] = struct{}{}
if len(w.seen) > w.limit {
return errRowLimit
}
}
fn(idx, vb)
return nil
})
}
// runTable runs fn under the table timeout. It returns the status, a detail
// and a non-nil error only when ctx itself is done.
func runTable(ctx context.Context, sess *snmp.Session, set settings, fn func(*walker) error) (status, detail string, err error) {
tctx, cancel := context.WithTimeout(ctx, set.timeout)
defer cancel()
w := &walker{sess: sess, ctx: tctx, maxRep: set.maxRep, limit: set.limit, seen: map[string]struct{}{}}
ferr := fn(w)
switch {
case ferr == nil:
return StatusOK, "", nil
case ctx.Err() != nil:
return "", "", ctx.Err()
case errors.Is(ferr, errRowLimit):
return StatusPartial, fmt.Sprintf("more than %d rows", set.limit), nil
case tctx.Err() != nil:
return StatusPartial, fmt.Sprintf("timeout after %s", set.timeout), nil
}
return StatusError, ferr.Error(), nil
}
type settings struct {
maxRep int
limit int
timeout time.Duration
}
func settingsFor(r *profile.Resolved) settings {
s := settings{maxRep: defaultMaxRep, limit: DefaultRowLimit, timeout: DefaultTableTimeout}
if r != nil {
if r.SNMP.MaxRepetitions > 0 {
s.maxRep = r.SNMP.MaxRepetitions
}
if r.SNMP.TableRowLimit > 0 {
s.limit = r.SNMP.TableRowLimit
}
if r.SNMP.TableTimeout > 0 {
s.timeout = r.SNMP.TableTimeout
}
}
return s
}
// pickProfile applies the profile rules documented on Options.Current.
func pickProfile(set *profile.Set, current, oid, descr string) (r *profile.Resolved, missing string) {
if set == nil {
return nil, ""
}
if current != "" && current != "generic" {
r = set.Get(current)
if _, ok := set.Lookup(current); !ok && r != nil {
missing = current
}
if r != nil {
return r, missing
}
}
if r = set.Match(oid, descr); r != nil {
return r, ""
}
return set.Get("generic"), ""
}
// Collect walks the system group, the interface tables, ENTITY-MIB serials and
// the profile's tables over sess, which must be a "discovery" session. It
// writes nothing.
func Collect(ctx context.Context, sess *snmp.Session, clk clock.Clock, opt Options) (*Result, error) {
c := &collector{sess: sess, res: &Result{StartedAt: clk.Now().UTC()}}
if err := c.walkSystem(ctx); err != nil {
return nil, fmt.Errorf("walk system group: %w", err)
}
c.res.Profile, c.res.MissingProfile = pickProfile(opt.Profiles, opt.Current, c.res.SysObjectID, c.res.SysDescr)
c.set = settingsFor(c.res.Profile)
steps := []func(context.Context) error{c.walkInterfaces, c.walkChassisSerial, c.walkProfileTables}
for _, step := range steps {
if err := step(ctx); err != nil {
return nil, err
}
}
c.res.FinishedAt = clk.Now().UTC()
return c.res, nil
}
// collector holds the state of one Collect run; each method is one step.
type collector struct {
sess *snmp.Session
res *Result
set settings
}
// walkSystem reads sysDescr, sysObjectID, sysUpTime and sysName.
func (c *collector) walkSystem(ctx context.Context) error {
return c.sess.Walk(ctx, oidSystem, 0, func(vb snmp.Varbind) error {
switch vb.OID {
case oidSystem + ".1.0":
c.res.SysDescr = text(vb)
case oidSystem + ".2.0":
c.res.SysObjectID, _ = vb.Value.(string)
case oidSystem + ".3.0":
if v, ok := vb.Uint64(); ok {
c.res.SysUpTime = time.Duration(v) * 10 * time.Millisecond
}
case oidSystem + ".5.0":
c.res.SysName = text(vb)
}
return nil
})
}
// ifaceColumn is one ifTable/ifXTable column and how it fills an Iface.
type ifaceColumn struct {
root string
set func(*Iface, snmp.Varbind)
}
var ifaceColumns = []ifaceColumn{
{oidIfTable + ".1", func(*Iface, snmp.Varbind) {}},
{oidIfTable + ".2", func(i *Iface, vb snmp.Varbind) { i.Descr = text(vb) }},
{oidIfTable + ".3", func(i *Iface, vb snmp.Varbind) { i.Type = num(vb) }},
{oidIfTable + ".5", func(i *Iface, vb snmp.Varbind) { i.Speed = num(vb) }},
{oidIfTable + ".6", func(i *Iface, vb snmp.Varbind) { i.PhysAddr = mac(vb) }},
{oidIfTable + ".7", func(i *Iface, vb snmp.Varbind) { i.Admin = num(vb) }},
{oidIfTable + ".8", func(i *Iface, vb snmp.Varbind) { i.Oper = num(vb) }},
{oidIfXTable + ".1", func(i *Iface, vb snmp.Varbind) { i.Name = text(vb) }},
{oidIfXTable + ".15", func(i *Iface, vb snmp.Varbind) { i.HighSpeed = num(vb) }},
{oidIfXTable + ".18", func(i *Iface, vb snmp.Varbind) { i.Alias = text(vb) }},
{oidIfXTable + ".6", func(i *Iface, _ snmp.Varbind) { i.HasHC = true }},
}
// ifaceSet accumulates Iface rows by ifIndex while columns are walked.
type ifaceSet map[int64]*Iface
func (s ifaceSet) get(idx int64) *Iface {
i := s[idx]
if i == nil {
i = &Iface{IfIndex: idx}
s[idx] = i
}
return i
}
// walk reads every column; only single-index rows are interface rows.
func (s ifaceSet) walk(w *walker) error {
for _, c := range ifaceColumns {
err := w.column(c.root, func(idx string, vb snmp.Varbind) {
if n, err := strconv.ParseInt(idx, 10, 64); err == nil {
c.set(s.get(n), vb)
}
})
if err != nil {
return err
}
}
return nil
}
// walkInterfaces reads ifTable and ifXTable, which count as one table for the row limit.
func (c *collector) walkInterfaces(ctx context.Context) error {
ifs := ifaceSet{}
status, detail, err := runTable(ctx, c.sess, c.set, ifs.walk)
if err != nil {
return err
}
if status == StatusError {
return fmt.Errorf("walk interface tables: %s", detail)
}
c.res.IfacePartial = status == StatusPartial
c.res.Ifaces = make([]Iface, 0, len(ifs))
for _, i := range ifs {
c.res.Ifaces = append(c.res.Ifaces, *i)
}
sortIfaces(c.res.Ifaces)
c.res.Tables = append(c.res.Tables, TableResult{Name: "interfaces", Status: status, Detail: detail,
Rows: make([]store.Component, len(c.res.Ifaces))})
return nil
}
// entity is one entPhysicalTable row: only the class and serial are needed.
type entity struct{ class, serial string }
// entitySet accumulates entities by their entPhysicalIndex.
type entitySet map[string]*entity
// walk reads entPhysicalClass and entPhysicalSerialNum.
func (s entitySet) walk(w *walker) error {
cols := []struct {
root string
set func(*entity, snmp.Varbind)
}{
{oidEntClass, func(e *entity, vb snmp.Varbind) { e.class = strconv.FormatInt(num(vb), 10) }},
{oidEntSer, func(e *entity, vb snmp.Varbind) { e.serial = strings.TrimSpace(text(vb)) }},
}
for _, col := range cols {
err := w.column(col.root, func(idx string, vb snmp.Varbind) {
e := s[idx]
if e == nil {
e = &entity{}
s[idx] = e
}
col.set(e, vb)
})
if err != nil {
return err
}
}
return nil
}
// walkChassisSerial reads ENTITY-MIB and keeps the chassis serial when the walk was complete.
func (c *collector) walkChassisSerial(ctx context.Context) error {
ents := entitySet{}
status, detail, err := runTable(ctx, c.sess, c.set, ents.walk)
if err != nil {
return err
}
if status == StatusOK {
c.res.HWSerial = chassisSerial(ents, func(e *entity) (string, string) { return e.class, e.serial })
}
c.res.Tables = append(c.res.Tables, TableResult{Name: "entPhysicalSerialNum", Status: status, Detail: detail})
return nil
}
// walkProfileTables reads every table the resolved profile declares.
func (c *collector) walkProfileTables(ctx context.Context) error {
if c.res.Profile == nil {
return nil
}
for _, spec := range tableSpecs(c.res.Profile) {
tr := TableResult{Name: spec.key, Components: true}
if spec.skip != "" {
tr.Status, tr.Detail = StatusSkipped, spec.skip
c.res.Tables = append(c.res.Tables, tr)
continue
}
var rows []store.Component
var err error
tr.Status, tr.Detail, err = runTable(ctx, c.sess, c.set, func(w *walker) error {
var werr error
rows, werr = spec.walk(w)
return werr
})
if err != nil {
return err
}
if tr.Status == StatusOK {
tr.Rows = rows
}
c.res.Tables = append(c.res.Tables, tr)
}
return nil
}
// chassisSerial returns the serial of the lowest-index chassis (entPhysicalClass 3),
// else of the lowest-index entity with a serial.
func chassisSerial[E any](ents map[string]E, f func(E) (class, serial string)) string {
keys := make([]string, 0, len(ents))
for k := range ents {
keys = append(keys, k)
}
slices.SortFunc(keys, func(a, b string) int { return compareIdx(a, b) })
first := ""
for _, k := range keys {
class, serial := f(ents[k])
if serial == "" {
continue
}
if class == "3" {
return serial
}
if first == "" {
first = serial
}
}
return first
}
func compareIdx(a, b string) int {
x, ex := strconv.ParseInt(a, 10, 64)
y, ey := strconv.ParseInt(b, 10, 64)
if ex == nil && ey == nil {
return cmp.Compare(x, y)
}
return strings.Compare(a, b)
}
func sortIfaces(s []Iface) {
slices.SortFunc(s, func(a, b Iface) int { return cmp.Compare(a.IfIndex, b.IfIndex) })
}
func text(vb snmp.Varbind) string {
switch v := vb.Value.(type) {
case []byte:
// Some agents NUL-terminate strings (HP JetDirect supply names); a NUL
// breaks the SQL the store builds.
return strings.Map(func(r rune) rune {
if r == 0 {
return -1
}
return r
}, strings.ToValidUTF8(string(v), "?"))
case string:
return v
}
if n, ok := vb.Int64(); ok {
return strconv.FormatInt(n, 10)
}
return ""
}
func num(vb snmp.Varbind) int64 {
v, _ := vb.Int64()
return v
}
func mac(vb snmp.Varbind) string {
b, _ := vb.Value.([]byte)
if len(b) == 0 {
return ""
}
h := hex.EncodeToString(b)
parts := make([]string, 0, len(b))
for i := 0; i < len(h); i += 2 {
parts = append(parts, h[i:i+2])
}
return strings.Join(parts, ":")
}

View File

@@ -0,0 +1,763 @@
package discovery_test
import (
"context"
"encoding/json"
"os"
"path/filepath"
"slices"
"strconv"
"strings"
"testing"
"time"
"github.com/gosnmp/gosnmp"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/testutil"
)
const (
ifT = "1.3.6.1.2.1.2.2.1."
ifX = "1.3.6.1.2.1.31.1.1.1."
entCl = "1.3.6.1.2.1.47.1.1.1.1.5."
entSn = "1.3.6.1.2.1.47.1.1.1.1.11."
)
type env struct {
t *testing.T
clk *clock.Fake
st store.Store
deps discovery.Deps
}
func newEnv(t *testing.T, profiles *profile.Set) *env {
t.Helper()
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
st := testutil.OpenStore(t, clk)
return &env{t: t, clk: clk, st: st, deps: discovery.Deps{Store: st, Profiles: profiles, Clock: clk}}
}
func shipped(t *testing.T) *profile.Set {
t.Helper()
s, err := profile.Load("")
if err != nil {
t.Fatal(err)
}
return s
}
// device creates a network device pointing at ::1 with the given SNMP version.
func (e *env) device(name, version string) *store.Device {
e.t.Helper()
ctx := context.Background()
cred := &store.SNMPCredential{Name: name + "-cred", Version: version, Community: "public"}
if err := e.st.CreateCredential(ctx, cred); err != nil {
e.t.Fatal(err)
}
dev := &store.Device{Name: name, Kind: store.KindNetwork, SNMPCredentialID: &cred.ID, Enabled: true, Profile: "generic"}
if err := e.st.CreateDevice(ctx, dev); err != nil {
e.t.Fatal(err)
}
if err := e.st.SetAddresses(ctx, dev.ID, []string{"::1"}); err != nil {
e.t.Fatal(err)
}
return dev
}
// discover runs Discover against an agent serving rec.
func (e *env) discover(dev *store.Device, rec *simagent.Recording) (*discovery.Report, *simagent.Agent) {
e.t.Helper()
a := testutil.StartAgent(e.t, simagent.Config{Recording: rec})
e.deps.SNMPPort = int(a.Addr().Port())
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
rep, err := discovery.Discover(ctx, e.deps, dev)
if err != nil {
e.t.Fatal(err)
}
return rep, a
}
func (e *env) ifaces(dev *store.Device, retired bool) map[string]store.Interface {
e.t.Helper()
list, err := e.st.ListInterfaces(context.Background(), dev.ID, retired)
if err != nil {
e.t.Fatal(err)
}
out := map[string]store.Interface{}
for _, i := range list {
out[i.IfName] = i
}
return out
}
func (e *env) audit() []store.AuditEntry {
e.t.Helper()
rows, _, err := e.st.ListAudit(context.Background(), store.Page{Limit: store.MaxLimit})
if err != nil {
e.t.Fatal(err)
}
return rows
}
func (e *env) reviews(kind string) []store.IdentityReview {
e.t.Helper()
rows, _, err := e.st.ListOpenIdentityReviews(context.Background(), store.Page{Limit: store.MaxLimit})
if err != nil {
e.t.Fatal(err)
}
var out []store.IdentityReview
for _, r := range rows {
if r.Kind == kind {
out = append(out, r)
}
}
return out
}
func auditActions(rows []store.AuditEntry) map[string]int {
m := map[string]int{}
for _, r := range rows {
m[r.Action]++
}
return m
}
// edit rewrites a recording record by record; fn may change r in place and
// returns false to drop it.
func edit(rec *simagent.Recording, fn func(r *simagent.Record) bool) *simagent.Recording {
var out []simagent.Record
for _, r := range rec.Records() {
if fn(&r) {
out = append(out, r)
}
}
return simagent.NewRecording(out)
}
func add(rec *simagent.Recording, more ...simagent.Record) *simagent.Recording {
return simagent.NewRecording(append(slices.Clone(rec.Records()), more...))
}
// ifRow returns the row index of an ifTable/ifXTable record, or -1.
func ifRow(oid string) (col string, idx int) {
for _, p := range []string{ifT, ifX} {
if rest, ok := strings.CutPrefix(oid, p); ok {
c, i, _ := strings.Cut(rest, ".")
n, _ := strconv.Atoi(i)
return p + c, n
}
}
return "", -1
}
func entity(serial string) []simagent.Record {
return []simagent.Record{
{OID: entCl + "1", Type: gosnmp.Integer, Value: 3},
{OID: entSn + "1", Type: gosnmp.OctetString, Value: []byte(serial)},
}
}
func generic(ports int, sysName string, hc bool) *simagent.Recording {
return fixturegen.Generic(fixturegen.Options{Ports: ports, SysName: sysName, HCCounters: hc})
}
func TestRenumberKeepsULIDs(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("sw1", "v2c")
rec := generic(8, "sw1", true)
e.discover(dev, rec)
before := e.ifaces(dev, false)
if len(before) != 8 {
t.Fatalf("%d interfaces", len(before))
}
// Reverse every ifIndex: 1..8 -> 108..101.
re := edit(rec, func(r *simagent.Record) bool {
col, i := ifRow(r.OID)
if i < 0 {
return true
}
r.OID = col + "." + strconv.Itoa(109-i)
if col == ifT+"1" {
r.Value = 109 - i
}
return true
})
rep, _ := e.discover(dev, re)
after := e.ifaces(dev, true)
if len(after) != 8 || rep.Matched != 8 || rep.New != 0 || rep.Retired != 0 || len(rep.Reviews) != 0 {
t.Fatalf("report %+v, %d rows", rep, len(after))
}
for name, b := range before {
a := after[name]
if a.ID != b.ID || a.IfIndex != 109-b.IfIndex || a.RetiredAt != nil {
t.Errorf("%s: %+v -> %+v", name, b, a)
}
}
if n := len(e.reviews(store.ReviewAmbiguousMatch)); n != 0 {
t.Errorf("%d reviews", n)
}
// A third, identical run is quiet in the audit log.
n := len(e.audit())
e.discover(dev, re)
if got := len(e.audit()); got != n {
t.Errorf("unchanged discovery wrote %d audit rows", got-n)
}
}
func TestStackMemberSwapOpensOneReview(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("stack", "v2c")
rec := generic(8, "stack", true)
e.discover(dev, rec)
before := e.ifaces(dev, false)
// ge-0/0/0 and ge-0/0/1 swap name and ifIndex: the rows keep descr, MAC and
// alias, but ifName (and so what a name-only matcher would follow) moves.
swapped := edit(rec, func(r *simagent.Record) bool {
col, i := ifRow(r.OID)
if (i == 1 || i == 2) && col == ifX+"1" {
r.Value = []byte("ge-0/0/" + strconv.Itoa(2-i))
}
return true
})
rep, _ := e.discover(dev, swapped)
if rep.Split != 2 || len(rep.Reviews) != 1 || rep.Retired != 0 {
t.Fatalf("report %+v", rep)
}
revs := e.reviews(store.ReviewAmbiguousMatch)
if len(revs) != 1 || *revs[0].DeviceID != dev.ID {
t.Fatalf("reviews %+v", revs)
}
var cands []struct{ ID string }
if err := json.Unmarshal([]byte(revs[0].Candidates), &cands); err != nil || len(cands) != 2 {
t.Fatalf("candidates %s %v", revs[0].Candidates, err)
}
// Both histories survive: the original rows are still active and untouched.
all, _ := e.st.ListInterfaces(context.Background(), dev.ID, true)
byID := map[string]store.Interface{}
for _, i := range all {
byID[i.ID] = i
}
for _, name := range []string{"ge-0/0/0", "ge-0/0/1"} {
old := before[name]
got, ok := byID[old.ID]
if !ok || got.RetiredAt != nil || got.IfName != old.IfName || got.IfPhysAddr != old.IfPhysAddr || got.IfIndex != old.IfIndex {
t.Errorf("%s: original row changed: %+v", name, got)
}
if !slices.ContainsFunc(cands, func(c struct{ ID string }) bool { return c.ID == old.ID }) {
t.Errorf("%s not a candidate", name)
}
}
if len(all) != 10 {
t.Errorf("%d rows, want 8 + 2 new", len(all))
}
if got := auditActions(e.audit())["interface.split"]; got != 2 {
t.Errorf("%d split audit rows", got)
}
// Discovering again changes nothing: no second review, no more rows.
rep, _ = e.discover(dev, swapped)
all2, _ := e.st.ListInterfaces(context.Background(), dev.ID, true)
if len(e.reviews(store.ReviewAmbiguousMatch)) != 1 || len(all2) != 10 || rep.Split != 0 || rep.Retired != 0 {
t.Fatalf("second run: %+v, %d rows, %d reviews", rep, len(all2), len(e.reviews(store.ReviewAmbiguousMatch)))
}
}
func TestRenameIsNotAutoMerged(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("sw", "v2c")
rec := generic(4, "sw", true)
e.discover(dev, rec)
before := e.ifaces(dev, false)
renamed := edit(rec, func(r *simagent.Record) bool {
col, i := ifRow(r.OID)
if i == 2 && (col == ifX+"1" || col == ifT+"2") {
r.Value = []byte("xe-0/0/1")
}
return true
})
rep, _ := e.discover(dev, renamed)
if rep.Split != 1 || len(rep.Reviews) != 1 || rep.Retired != 0 {
t.Fatalf("report %+v", rep)
}
got := e.ifaces(dev, true)
if got["ge-0/0/1"].ID != before["ge-0/0/1"].ID || got["ge-0/0/1"].RetiredAt != nil {
t.Error("old row not kept")
}
if n := got["xe-0/0/1"]; n.ID == "" || n.ID == before["ge-0/0/1"].ID {
t.Errorf("renamed port: %+v", n)
}
}
func TestRMAKeepsHistoryAndOpensReview(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("rtr", "v2c")
rec1 := add(generic(6, "rtr", true), entity("SN-OLD-1")...)
rep, _ := e.discover(dev, rec1)
if rep.HardwareReplaced {
t.Fatal("first serial is not a replacement")
}
cur, _ := e.st.GetDeviceByID(context.Background(), dev.ID)
if cur.HWSerial != "SN-OLD-1" {
t.Fatalf("hw_serial %q", cur.HWSerial)
}
before := e.ifaces(dev, false)
rec2 := add(generic(6, "rtr", true), entity("SN-NEW-2")...)
rep, _ = e.discover(dev, rec2)
if !rep.HardwareReplaced || rep.Matched != 6 || rep.New != 0 || rep.Retired != 0 {
t.Fatalf("report %+v", rep)
}
cur, _ = e.st.GetDeviceByID(context.Background(), dev.ID)
if cur.HWSerial != "SN-NEW-2" || cur.Slug != dev.Slug {
t.Fatalf("device %+v", cur)
}
for name, i := range e.ifaces(dev, false) {
if before[name].ID != i.ID {
t.Errorf("%s changed ID", name)
}
}
revs := e.reviews(store.ReviewHardwareReplaced)
if len(revs) != 1 || !strings.Contains(revs[0].Evidence, "SN-NEW-2") || !strings.Contains(revs[0].Candidates, "SN-OLD-1") {
t.Fatalf("reviews %+v", revs)
}
var found bool
for _, a := range e.audit() {
if a.Action == "device.hardware_replaced" && a.Actor == "discovery" && strings.Contains(a.After, "SN-NEW-2") && strings.Contains(a.Before, "SN-OLD-1") {
found = true
}
}
if !found {
t.Error("no hardware_replaced audit row")
}
// Same serial again: nothing new.
e.discover(dev, rec2)
if len(e.reviews(store.ReviewHardwareReplaced)) != 1 {
t.Error("second review for the same serial")
}
}
func TestSerialChangeWithoutMatchingInterfacesIsNotAnRMA(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("rtr", "v2c")
e.discover(dev, add(generic(6, "rtr", true), entity("A")...))
rep, _ := e.discover(dev, add(generic(3, "rtr", true), entity("B")...)) // 3 ports vanish
if rep.HardwareReplaced || len(e.reviews(store.ReviewHardwareReplaced)) != 0 {
t.Fatalf("report %+v", rep)
}
cur, _ := e.st.GetDeviceByID(context.Background(), dev.ID)
if cur.HWSerial != "A" {
t.Errorf("hw_serial %q", cur.HWSerial)
}
}
func TestChassisSerialPrefersChassisClass(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("rtr", "v2c")
rec := add(generic(4, "rtr", true),
simagent.Record{OID: entCl + "1", Type: gosnmp.Integer, Value: 6}, // power supply, first by index
simagent.Record{OID: entSn + "1", Type: gosnmp.OctetString, Value: []byte("PSU-1")},
simagent.Record{OID: entCl + "2", Type: gosnmp.Integer, Value: 3},
simagent.Record{OID: entSn + "2", Type: gosnmp.OctetString, Value: []byte("CHASSIS-2")},
simagent.Record{OID: entCl + "10", Type: gosnmp.Integer, Value: 3},
simagent.Record{OID: entSn + "10", Type: gosnmp.OctetString, Value: []byte("CHASSIS-10")})
e.discover(dev, rec)
cur, _ := e.st.GetDeviceByID(context.Background(), dev.ID)
if cur.HWSerial != "CHASSIS-2" {
t.Errorf("hw_serial %q", cur.HWSerial)
}
// No chassis: first non-empty by numeric index.
dev2 := e.device("rtr2", "v2c")
e.discover(dev2, add(generic(4, "rtr2", true),
simagent.Record{OID: entCl + "9", Type: gosnmp.Integer, Value: 6},
simagent.Record{OID: entSn + "9", Type: gosnmp.OctetString, Value: []byte("PSU-9")},
simagent.Record{OID: entCl + "10", Type: gosnmp.Integer, Value: 6},
simagent.Record{OID: entSn + "10", Type: gosnmp.OctetString, Value: []byte("PSU-10")}))
cur, _ = e.st.GetDeviceByID(context.Background(), dev2.ID)
if cur.HWSerial != "PSU-9" {
t.Errorf("hw_serial %q", cur.HWSerial)
}
}
func TestJunosTunnelsAndSubinterfacesNotExcluded(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("mx1", "v2c")
rec := fixturegen.Generic(fixturegen.Options{Ports: 8, SysName: "mx1", HCCounters: true,
SysObjectID: "1.3.6.1.4.1.2636.1.1.1.2.21", SysDescr: "Juniper Networks, Inc. mx480 internet router"})
rec = edit(rec, func(r *simagent.Record) bool { // port 1 becomes the loopback
if col, i := ifRow(r.OID); i == 1 && (col == ifX+"1" || col == ifT+"2") {
r.Value = []byte("lo0")
}
return true
})
rep, _ := e.discover(dev, rec)
if rep.Profile != "juniper-junos" {
t.Fatalf("profile %q", rep.Profile)
}
cur, _ := e.st.GetDeviceByID(context.Background(), dev.ID)
if cur.Profile != "juniper-junos" {
t.Errorf("stored profile %q", cur.Profile)
}
ifs := e.ifaces(dev, false)
gre, vlan, lo := ifs["gr-0/0/0"], ifs["ge-0/0/0.100"], ifs["lo0"]
if gre.ID == "" || gre.IfType != 131 || gre.Excluded {
t.Errorf("GRE tunnel: %+v", gre)
}
if vlan.ID == "" || vlan.IfType != 135 || vlan.Excluded {
t.Errorf("subinterface: %+v", vlan)
}
if lo.ID == "" || !lo.Excluded {
t.Errorf("lo0 should be excluded: %+v", lo)
}
for name, i := range ifs {
if name != "lo0" && i.Excluded {
t.Errorf("%s excluded", name)
}
}
}
func TestThirtyTwoBitRule(t *testing.T) {
e := newEnv(t, shipped(t))
fast := e.device("fast", "v2c")
rec := fixturegen.Generic(fixturegen.Options{Ports: 4, SysName: "fast", Speed: 1_000_000_000}) // no HC
// ge-0/0/3 is a 10G port: ifSpeed saturated, ifHighSpeed 10000.
rec = edit(rec, func(r *simagent.Record) bool {
switch col, i := ifRow(r.OID); {
case i == 4 && col == ifT+"5":
r.Value = uint32(4294967295)
case i == 4 && col == ifX+"15":
r.Value = uint32(10000)
}
return true
})
rep, _ := e.discover(fast, rec)
ifs := e.ifaces(fast, false)
for _, name := range []string{"ge-0/0/0", "ge-0/0/1"} {
if i := ifs[name]; i.HasHCCounters || !i.Unreliable {
t.Errorf("%s: hc=%v unreliable=%v", name, i.HasHCCounters, i.Unreliable)
}
}
if i := ifs["ge-0/0/0.100"]; i.HasHCCounters || !i.Unreliable { // idx 4 is the VLAN subinterface (ifType 135)
t.Errorf("10G port: %+v", i)
}
if i := ifs["gr-0/0/0"]; i.Unreliable { // speed 0 tunnel
t.Errorf("tunnel unreliable: %+v", i)
}
cur, _ := e.st.GetDeviceByID(context.Background(), fast.ID)
if !slices.Contains(cur.Warnings, discovery.WarningFast32) || !slices.Contains(rep.Warnings, discovery.WarningFast32) {
t.Errorf("warnings %v / %v", cur.Warnings, rep.Warnings)
}
slow := e.device("slow", "v2c")
rep, _ = e.discover(slow, fixturegen.Generic(fixturegen.Options{Ports: 4, SysName: "slow", Speed: 10_000_000}))
for name, i := range e.ifaces(slow, false) {
if i.HasHCCounters || i.Unreliable {
t.Errorf("%s: hc=%v unreliable=%v", name, i.HasHCCounters, i.Unreliable)
}
}
cur, _ = e.st.GetDeviceByID(context.Background(), slow.ID)
if slices.Contains(cur.Warnings, discovery.WarningFast32) || len(rep.Warnings) != 0 {
t.Errorf("warnings %v / %v", cur.Warnings, rep.Warnings)
}
// The warning clears once the device gains HC counters.
e.deps.SNMPPort = 0
e.discover(fast, fixturegen.Generic(fixturegen.Options{Ports: 4, SysName: "fast", HCCounters: true}))
cur, _ = e.st.GetDeviceByID(context.Background(), fast.ID)
if slices.Contains(cur.Warnings, discovery.WarningFast32) {
t.Errorf("warning stuck: %v", cur.Warnings)
}
for name, i := range e.ifaces(fast, false) {
if i.Unreliable {
t.Errorf("%s still unreliable", name)
}
}
}
func TestV1DiscoversWithGetNextOnly(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("old", "v1")
rep, a := e.discover(dev, add(generic(6, "old", false), entity("V1-SERIAL")...))
st := a.Stats()
if st.GetBulk != 0 || st.GetNext == 0 {
t.Fatalf("agent stats %+v", st)
}
if rep.New != 6 || rep.Partial {
t.Fatalf("report %+v", rep)
}
if cur, _ := e.st.GetDeviceByID(context.Background(), dev.ID); cur.HWSerial != "V1-SERIAL" {
t.Errorf("hw_serial %q", cur.HWSerial)
}
// And v2c uses GETBULK.
dev2 := e.device("new", "v2c")
_, a2 := e.discover(dev2, generic(6, "new", true))
if st := a2.Stats(); st.GetBulk == 0 || st.GetNext != 0 {
t.Fatalf("v2c agent stats %+v", st)
}
}
func tinyProfiles(t *testing.T, limit int) *profile.Set {
t.Helper()
dir := t.TempDir()
y := "name: tiny\nextends: generic\npriority: 100\nmatch:\n sysObjectID: 1.3.6.1.4.1.8072.3.2.10\nsnmp:\n table_row_limit: " + strconv.Itoa(limit) + "\n"
if err := os.WriteFile(filepath.Join(dir, "tiny.yaml"), []byte(y), 0o644); err != nil {
t.Fatal(err)
}
s, err := profile.Load(dir)
if err != nil {
t.Fatal(err)
}
return s
}
func storageRows(n int) []simagent.Record {
const hrs = "1.3.6.1.2.1.25.2.3.1."
var out []simagent.Record
for i := 100; i < 100+n; i++ {
s := strconv.Itoa(i)
out = append(out,
simagent.Record{OID: hrs + "1." + s, Type: gosnmp.Integer, Value: i},
simagent.Record{OID: hrs + "3." + s, Type: gosnmp.OctetString, Value: []byte("vol" + s)},
simagent.Record{OID: hrs + "4." + s, Type: gosnmp.Integer, Value: 4096},
simagent.Record{OID: hrs + "5." + s, Type: gosnmp.Integer, Value: 1000},
simagent.Record{OID: hrs + "6." + s, Type: gosnmp.Integer, Value: 10})
}
return out
}
func components(t *testing.T, e *env, dev *store.Device) map[string]string {
t.Helper()
cs, err := e.st.ListComponents(context.Background(), dev.ID)
if err != nil {
t.Fatal(err)
}
out := map[string]string{}
for _, c := range cs {
out[c.TableName+"/"+c.Idx] = c.Label
}
return out
}
func TestComponentsStoredWithLabels(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("host", "v2c")
rep, _ := e.discover(dev, generic(4, "host", true))
if rep.Profile != "generic" || rep.Partial {
t.Fatalf("report %+v", rep)
}
got := components(t, e, dev)
const hr = "HOST-RESOURCES-MIB::hrStorageTable/"
if got[hr+"1"] != "Physical memory" || got[hr+"31"] != "/" {
t.Errorf("hrStorage components: %v", got)
}
// hrProcessorLoad rows are labelled by hrDeviceIndex and keyed by it.
const cpu = "HOST-RESOURCES-MIB::hrDeviceIndex/"
if got[cpu+"196608"] != "196608" || got[cpu+"196609"] != "196609" {
t.Errorf("cpu components: %v", got)
}
for k := range got {
if strings.HasPrefix(k, "interfaces") || strings.Contains(k, "entPhysicalSerialNum") {
t.Errorf("non-table %q stored as a component", k)
}
}
d, err := e.st.LastDiscovery(context.Background(), dev.ID)
if err != nil || d.Partial {
t.Fatalf("discovery %+v %v", d, err)
}
var tj map[string]struct {
Status string
Rows int
}
if err := json.Unmarshal([]byte(d.TablesJSON), &tj); err != nil {
t.Fatal(err)
}
if tj["interfaces"].Rows != 4 || tj["HOST-RESOURCES-MIB::hrStorageTable"].Rows != 2 || tj["HOST-RESOURCES-MIB::hrStorageTable"].Status != "ok" {
t.Errorf("tables_json %s", d.TablesJSON)
}
if tj["LLDP-MIB::lldpRemTable"].Status != "ok" {
t.Errorf("lldp %+v", tj["LLDP-MIB::lldpRemTable"])
}
// A row that disappears is retired.
e.discover(dev, edit(generic(4, "host", true), func(r *simagent.Record) bool {
return !strings.Contains(r.OID, ".25.2.3.1.") || !strings.HasSuffix(r.OID, ".31")
}))
got = components(t, e, dev)
if _, ok := got[hr+"31"]; ok || got[hr+"1"] == "" {
t.Errorf("after removal: %v", got)
}
}
func TestPartialTableKeepsPreviousComponents(t *testing.T) {
e := newEnv(t, tinyProfiles(t, 5))
dev := e.device("host", "v2c")
rep, _ := e.discover(dev, generic(4, "host", true))
if rep.Profile != "tiny" || rep.Partial {
t.Fatalf("first: %+v", rep)
}
before := components(t, e, dev)
rep, _ = e.discover(dev, add(generic(4, "host", true), storageRows(6)...)) // 8 storage rows > 5
if !rep.Partial {
t.Fatalf("report not partial: %+v", rep)
}
d, _ := e.st.LastDiscovery(context.Background(), dev.ID)
if !d.Partial || !strings.Contains(d.TablesJSON, `"partial"`) || !strings.Contains(d.TablesJSON, "more than 5 rows") {
t.Fatalf("discovery %+v", d)
}
after := components(t, e, dev)
if len(after) != len(before) {
t.Fatalf("components changed:\n%v\n%v", before, after)
}
for k, v := range before {
if after[k] != v {
t.Errorf("%s: %q -> %q", k, v, after[k])
}
}
if !slices.ContainsFunc(rep.Tables, func(tr discovery.TableReport) bool {
return tr.Name == "HOST-RESOURCES-MIB::hrStorageTable" && tr.Status == discovery.StatusPartial
}) {
t.Errorf("tables %+v", rep.Tables)
}
}
func TestPartialIfTableRetiresNothing(t *testing.T) {
e := newEnv(t, tinyProfiles(t, 5))
dev := e.device("host", "v2c")
e.discover(dev, generic(5, "host", true))
before := e.ifaces(dev, false)
if len(before) != 5 {
t.Fatalf("%d interfaces", len(before))
}
rep, _ := e.discover(dev, generic(8, "host", true))
if !rep.Partial || rep.Retired != 0 || rep.New != 0 {
t.Fatalf("report %+v", rep)
}
after := e.ifaces(dev, true)
if len(after) != 5 {
t.Fatalf("%d rows after a partial walk", len(after))
}
for n, b := range before {
if after[n].ID != b.ID || after[n].RetiredAt != nil {
t.Errorf("%s changed: %+v", n, after[n])
}
}
// Back under the limit: a complete walk resumes normal matching.
rep, _ = e.discover(dev, generic(4, "host", true))
if rep.Partial {
t.Fatalf("%+v", rep)
}
}
func TestProfileMatchStoredAndMissingFallsBack(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("mx", "v2c")
rec := fixturegen.Generic(fixturegen.Options{Ports: 4, SysName: "mx", HCCounters: true, SysObjectID: "1.3.6.1.4.1.2636.1.1.1.2.21"})
rep, _ := e.discover(dev, rec)
cur, _ := e.st.GetDeviceByID(context.Background(), dev.ID)
if rep.Profile != "juniper-junos" || cur.Profile != "juniper-junos" || len(cur.Warnings) != 0 {
t.Fatalf("report %+v device %+v", rep, cur)
}
// The profile disappears: generic is used, the name stays and a warning persists.
lean := tinyProfilesWithout(t, "juniper-junos")
e.deps.Profiles = lean
rep, _ = e.discover(dev, rec)
cur, _ = e.st.GetDeviceByID(context.Background(), dev.ID)
if rep.ProfileMissing != "juniper-junos" || cur.Profile != "juniper-junos" ||
!slices.Contains(cur.Warnings, "profile_missing:juniper-junos") {
t.Fatalf("report %+v device %+v", rep, cur)
}
rep, _ = e.discover(dev, rec) // persists
cur, _ = e.st.GetDeviceByID(context.Background(), dev.ID)
if !slices.Contains(cur.Warnings, "profile_missing:juniper-junos") || rep.Profile != "juniper-junos" {
t.Fatalf("warning did not persist: %+v", cur.Warnings)
}
// It returns: the warning clears.
e.deps.Profiles = shipped(t)
e.discover(dev, rec)
cur, _ = e.st.GetDeviceByID(context.Background(), dev.ID)
if len(cur.Warnings) != 0 {
t.Fatalf("warnings after the profile returned: %v", cur.Warnings)
}
}
// tinyProfilesWithout loads the shipped profiles minus one, via a filesystem copy.
func tinyProfilesWithout(t *testing.T, name string) *profile.Set {
t.Helper()
dir := t.TempDir()
ents, err := os.ReadDir("../../profiles")
if err != nil {
t.Fatal(err)
}
for _, en := range ents {
if en.Name() == name+".yaml" || !strings.HasSuffix(en.Name(), ".yaml") {
continue
}
b, err := os.ReadFile(filepath.Join("../../profiles", en.Name()))
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, en.Name()), b, 0o644); err != nil {
t.Fatal(err)
}
}
s, err := profile.LoadFS(os.DirFS(dir))
if err != nil {
t.Fatal(err)
}
return s
}
func TestAuditRowsForEveryDecisionKind(t *testing.T) {
e := newEnv(t, shipped(t))
dev := e.device("sw", "v2c")
base := generic(6, "sw", true)
e.discover(dev, base) // new x6
e.discover(dev, edit(base, func(r *simagent.Record) bool { _, i := ifRow(r.OID); return i != 5 })) // retired ge-0/0/4
e.discover(dev, base) // unretired
renumbered := edit(base, func(r *simagent.Record) bool { // matched, ifindex changed
col, i := ifRow(r.OID)
if i == 1 {
r.OID = col + ".51"
if col == ifT+"1" {
r.Value = 51
}
}
return true
})
e.discover(dev, renumbered)
e.discover(dev, edit(renumbered, func(r *simagent.Record) bool { // split via rename
col, i := ifRow(r.OID)
if i == 2 && (col == ifX+"1" || col == ifT+"2") {
r.Value = []byte("renamed")
}
return true
}))
e.discover(dev, add(edit(renumbered, func(r *simagent.Record) bool { return true }), entity("S1")...))
e.discover(dev, add(edit(renumbered, func(r *simagent.Record) bool { return true }), entity("S2")...))
got := auditActions(e.audit())
for _, k := range []string{"interface.new", "interface.matched", "interface.retired", "interface.unretired", "interface.split", "device.hardware_replaced"} {
if got[k] == 0 {
t.Errorf("no %s audit row; have %v", k, got)
}
}
for _, a := range e.audit() {
if strings.HasPrefix(a.Action, "interface.") && (a.Actor != "discovery" || a.After == "" && a.Action != "interface.retired") {
t.Errorf("audit row %+v", a)
}
if a.Action == "interface.matched" && (!strings.Contains(a.Before, `"ifindex":1`) || !strings.Contains(a.After, `"ifindex":51`)) {
t.Errorf("matched evidence: %+v", a)
}
}
}

View File

@@ -1,28 +1,30 @@
// Package discovery learns what a device has. In M1 it is the seed walk run
// when a device is added: system group plus the IF-MIB tables, creating
// interface rows with ULIDs.
// Package discovery learns what a device has: the system group, interfaces
// (with identity matching), the chassis serial and the rows of the profile's
// discovery tables. It runs in two steps so a slow device never holds a
// database transaction: Collect walks over SNMP and returns a Result; Apply
// writes that Result in one transaction. Discover does both for a stored device.
//
// TODO(M2): identity matching beyond ifName and ifDescr+MAC (stack-member
// swaps, RMA), identity_reviews for ambiguous matches, retiring interfaces that
// disappeared, profiles, per-table limits and the `partial` flag.
// Components: a profile table is stored in device_components under a table
// key. The key is the metric's Table field when set (e.g.
// "HOST-RESOURCES-MIB::hrStorageTable"); otherwise "<MIB>::<label column>" of
// the symbol its index_label column names (e.g. "HOST-RESOURCES-MIB::hrDeviceIndex"
// for hrProcessorLoad). Use TableKey; the poller must read components with the
// same key.
package discovery
import (
"cmp"
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/netip"
"slices"
"strconv"
"strings"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/snmp"
"github.com/v10networks/nmon/internal/snmptarget"
"github.com/v10networks/nmon/internal/store"
"github.com/v10networks/nmon/internal/ulid"
)
// Walk roots.
@@ -30,226 +32,169 @@ const (
oidSystem = "1.3.6.1.2.1.1"
oidIfTable = "1.3.6.1.2.1.2.2.1"
oidIfXTable = "1.3.6.1.2.1.31.1.1.1"
oidEntClass = "1.3.6.1.2.1.47.1.1.1.1.5"
oidEntSer = "1.3.6.1.2.1.47.1.1.1.1.11"
)
// Target builds the SNMP target for a device's credential. port 0 means 161.
func Target(addr netip.Addr, port int, c *store.SNMPCredential, clk clock.Clock) snmp.Target {
t := snmp.Target{Addr: netip.AddrPortFrom(addr, uint16(port)), Clock: clk}
t.Cred = snmp.Credential{
Community: c.Community, V3User: c.V3User, V3AuthProto: c.V3AuthProto, V3AuthPass: c.V3AuthPass,
V3PrivProto: c.V3PrivProto, V3PrivPass: c.V3PrivPass, V3Context: c.V3Context,
// Defaults when no profile applies.
const (
DefaultRowLimit = 10000
DefaultTableTimeout = 30 * time.Second
defaultMaxRep = 25
)
// Deps are what Discover needs.
type Deps struct {
Store store.Store
Profiles *profile.Set // nil: no profile matching, default limits
Clock clock.Clock // default clock.Real()
// SNMPPort overrides every device's port (dev and tests); 0 = use the device's.
SNMPPort int
// Dial opens a "discovery" session; default snmp.Dial.
Dial func(ctx context.Context, t snmp.Target, path string) (*snmp.Session, error)
Logger *slog.Logger
}
func (d Deps) clock() clock.Clock {
if d.Clock == nil {
return clock.Real()
}
t.Cred.Version = strings.TrimPrefix(c.Version, "v")
return t
return d.Clock
}
// Iface is one interface as the device reports it.
type Iface struct {
IfIndex int64
Name, Descr string
Alias string
PhysAddr string // colon-separated lower-case hex
Speed int64
HighSpeed int64
Type int64
Admin, Oper int64
HasHC bool
func (d Deps) dial(ctx context.Context, t snmp.Target) (*snmp.Session, error) {
if d.Dial != nil {
return d.Dial(ctx, t, "discovery")
}
return snmp.Dial(ctx, t, "discovery")
}
// Result is what Collect learned.
type Result struct {
SysDescr, SysObjectID, SysName string
SysUpTime time.Duration
Ifaces []Iface
StartedAt, FinishedAt time.Time
}
// Collect walks the system group and the interface tables over sess, which
// must be a "discovery" session.
func Collect(ctx context.Context, sess *snmp.Session, clk clock.Clock) (*Result, error) {
res := &Result{StartedAt: clk.Now().UTC()}
err := sess.Walk(ctx, oidSystem, 0, func(vb snmp.Varbind) error {
switch vb.OID {
case oidSystem + ".1.0":
res.SysDescr = text(vb)
case oidSystem + ".2.0":
res.SysObjectID, _ = vb.Value.(string)
case oidSystem + ".3.0":
if v, ok := vb.Uint64(); ok {
res.SysUpTime = time.Duration(v) * 10 * time.Millisecond
}
case oidSystem + ".5.0":
res.SysName = text(vb)
}
return nil
})
// Discover walks dev (re-read from the store, so a stale copy is harmless) and
// records the result. Addresses are tried preferred first; the first that
// answers the system group is used.
func Discover(ctx context.Context, deps Deps, dev *store.Device) (*Report, error) {
cur, err := deps.Store.GetDeviceByID(ctx, dev.ID)
if err != nil {
return nil, fmt.Errorf("walk system group: %w", err)
return nil, err
}
ifs := map[int64]*Iface{}
get := func(idx int64) *Iface {
i := ifs[idx]
if i == nil {
i = &Iface{IfIndex: idx}
ifs[idx] = i
if cur.SNMPCredentialID == nil {
return nil, fmt.Errorf("discovery: device %s has no SNMP credential", cur.Slug)
}
cred, err := deps.Store.GetCredential(ctx, *cur.SNMPCredentialID)
if err != nil {
return nil, err
}
addrs, err := deps.Store.ListAddresses(ctx, cur.ID)
if err != nil {
return nil, err
}
var order []netip.Addr
if a, err := netip.ParseAddr(cur.MgmtIP); err == nil {
order = append(order, a)
}
for _, a := range addrs {
if p, err := netip.ParseAddr(a.Addr); err == nil && !contains(order, p) {
order = append(order, p)
}
return i
}
for _, c := range []struct {
name string
root string
set func(*Iface, snmp.Varbind)
}{
{"ifIndex", oidIfTable + ".1", func(*Iface, snmp.Varbind) {}},
{"ifDescr", oidIfTable + ".2", func(i *Iface, vb snmp.Varbind) { i.Descr = text(vb) }},
{"ifType", oidIfTable + ".3", func(i *Iface, vb snmp.Varbind) { i.Type = num(vb) }},
{"ifSpeed", oidIfTable + ".5", func(i *Iface, vb snmp.Varbind) { i.Speed = num(vb) }},
{"ifPhysAddress", oidIfTable + ".6", func(i *Iface, vb snmp.Varbind) { i.PhysAddr = mac(vb) }},
{"ifAdminStatus", oidIfTable + ".7", func(i *Iface, vb snmp.Varbind) { i.Admin = num(vb) }},
{"ifOperStatus", oidIfTable + ".8", func(i *Iface, vb snmp.Varbind) { i.Oper = num(vb) }},
{"ifName", oidIfXTable + ".1", func(i *Iface, vb snmp.Varbind) { i.Name = text(vb) }},
{"ifHighSpeed", oidIfXTable + ".15", func(i *Iface, vb snmp.Varbind) { i.HighSpeed = num(vb) }},
{"ifAlias", oidIfXTable + ".18", func(i *Iface, vb snmp.Varbind) { i.Alias = text(vb) }},
{"ifHCInOctets", oidIfXTable + ".6", func(i *Iface, vb snmp.Varbind) { i.HasHC = true }},
} {
prefix := c.root + "."
err := sess.Walk(ctx, c.root, 0, func(vb snmp.Varbind) error {
idx, err := strconv.ParseInt(strings.TrimPrefix(vb.OID, prefix), 10, 64)
if err != nil {
return nil // not a plain single-index row
}
c.set(get(idx), vb)
return nil
})
if len(order) == 0 {
return nil, fmt.Errorf("discovery: device %s has no address", cur.Slug)
}
port := cur.SNMPPort
if deps.SNMPPort != 0 {
port = deps.SNMPPort
}
var errs []error
for _, a := range order {
res, err := collectFrom(ctx, deps, snmptarget.For(a, port, cred, deps.clock()), cur.Profile)
if err != nil {
return nil, fmt.Errorf("walk %s: %w", c.name, err)
if ctx.Err() != nil {
return nil, ctx.Err()
}
errs = append(errs, fmt.Errorf("%s: %w", a, err))
continue
}
rep, err := Apply(ctx, deps.Store, cur, res, deps.clock())
if err != nil {
return nil, err
}
*dev = *cur
return rep, nil
}
res.Ifaces = make([]Iface, 0, len(ifs))
for _, i := range ifs {
res.Ifaces = append(res.Ifaces, *i)
}
sortIfaces(res.Ifaces)
res.FinishedAt = clk.Now().UTC()
return res, nil
return nil, fmt.Errorf("discovery of %s failed: %w", cur.Slug, errors.Join(errs...))
}
// Seed discovers dev over sess (a "discovery" session) and records the result:
// sys_* fields, interface rows and a discoveries row.
func Seed(ctx context.Context, st store.Store, sess *snmp.Session, dev *store.Device, clk clock.Clock) error {
res, err := Collect(ctx, sess, clk)
// DiscoverID is Discover for a device ID; it is what the Scheduler runs.
func (d Deps) DiscoverID(ctx context.Context, id int64) error {
dev, err := d.Store.GetDeviceByID(ctx, id)
if err != nil {
return err
}
return Apply(ctx, st, dev, res, clk)
if dev.RetiredAt != nil || dev.Kind != store.KindNetwork || dev.SNMPCredentialID == nil {
return nil
}
rep, err := Discover(ctx, d, dev)
if err == nil && d.Logger != nil {
d.Logger.Info("discovery done", "device", dev.Slug, "profile", rep.Profile, "partial", rep.Partial,
"new", rep.New, "retired", rep.Retired, "split", rep.Split)
}
return err
}
// Apply writes a Collect result for dev. Existing interfaces are matched by
// ifName, then by ifDescr plus MAC, and keep their IDs; the rest get new ULIDs.
// Interfaces the device no longer reports are left alone (TODO(M2): retire or review).
func Apply(ctx context.Context, st store.Store, dev *store.Device, res *Result, clk clock.Clock) error {
return st.WithTx(ctx, func(tx store.Store) error {
existing, err := tx.ListInterfaces(ctx, dev.ID, false)
if err != nil {
return err
}
rows := match(existing, res.Ifaces, clk.Now())
// UpdateDevice writes every mutable column, so start from the stored row
// rather than a caller's copy that may predate SetAddresses.
cur, err := tx.GetDeviceByID(ctx, dev.ID)
if err != nil {
return err
}
cur.SysDescr, cur.SysObjectID, cur.SysName = res.SysDescr, res.SysObjectID, res.SysName
if cur.Profile == "" {
cur.Profile = "generic"
}
if err := tx.UpdateDevice(ctx, cur); err != nil {
return err
}
*dev = *cur
if err := tx.UpsertInterfaces(ctx, dev.ID, rows); err != nil {
return err
}
tables, _ := json.Marshal(map[string]int{"interfaces": len(rows)})
return tx.RecordDiscovery(ctx, &store.Discovery{
DeviceID: dev.ID, StartedAt: res.StartedAt, FinishedAt: res.FinishedAt, TablesJSON: string(tables),
})
})
func collectFrom(ctx context.Context, deps Deps, tg snmp.Target, current string) (*Result, error) {
sess, err := deps.dial(ctx, tg)
if err != nil {
return nil, err
}
defer func() { _ = sess.Close() }()
return Collect(ctx, sess, deps.clock(), Options{Profiles: deps.Profiles, Current: current})
}
// match pairs discovered interfaces with existing rows and returns the rows to upsert.
func match(existing []store.Interface, found []Iface, now time.Time) []store.Interface {
byName := map[string][]int{}
byDescrMAC := map[string][]int{}
for k, e := range existing {
if e.IfName != "" {
byName[e.IfName] = append(byName[e.IfName], k)
func contains(s []netip.Addr, a netip.Addr) bool {
for _, x := range s {
if x == a {
return true
}
key := e.IfDescr + "\x00" + e.IfPhysAddr
byDescrMAC[key] = append(byDescrMAC[key], k)
}
used := make([]bool, len(existing))
take := func(c []int) int {
for _, k := range c {
if !used[k] {
used[k] = true
return k
}
}
return -1
}
out := make([]store.Interface, 0, len(found))
for _, f := range found {
k := -1
if f.Name != "" {
k = take(byName[f.Name])
}
if k < 0 {
k = take(byDescrMAC[f.Descr+"\x00"+f.PhysAddr])
}
var row store.Interface
if k >= 0 {
row = existing[k]
} else {
row = store.Interface{ID: ulid.New(now)}
}
row.IfIndex, row.IfName, row.IfDescr, row.IfAlias = f.IfIndex, f.Name, f.Descr, f.Alias
row.IfPhysAddr, row.IfSpeed, row.IfHighSpeed, row.IfType = f.PhysAddr, f.Speed, f.HighSpeed, f.Type
row.AdminStatus, row.OperStatus, row.HasHCCounters = f.Admin, f.Oper, f.HasHC
out = append(out, row)
}
return out
return false
}
func sortIfaces(s []Iface) {
slices.SortFunc(s, func(a, b Iface) int { return cmp.Compare(a.IfIndex, b.IfIndex) })
}
func text(vb snmp.Varbind) string {
switch v := vb.Value.(type) {
case []byte:
return strings.ToValidUTF8(string(v), "?")
case string:
return v
// Seed discovers dev over sess (a "discovery" session) without profiles and
// records the result. Kept for tests and tools that already hold a session.
func Seed(ctx context.Context, st store.Store, sess *snmp.Session, dev *store.Device, clk clock.Clock) error {
res, err := Collect(ctx, sess, clk, Options{})
if err != nil {
return err
}
return ""
_, err = Apply(ctx, st, dev, res, clk)
return err
}
func num(vb snmp.Varbind) int64 {
v, _ := vb.Int64()
return v
// Table statuses in a Report and in discoveries.tables_json.
const (
StatusOK = "ok"
StatusPartial = "partial" // row limit or timeout: previous rows kept
StatusError = "error" // walk failed: previous rows kept
StatusSkipped = "skipped" // table not understood
)
// TableReport says how one table went.
type TableReport struct {
Name string `json:"-"`
Status string `json:"status"`
Rows int `json:"rows"`
Detail string `json:"detail,omitempty"`
}
func mac(vb snmp.Varbind) string {
b, _ := vb.Value.([]byte)
if len(b) == 0 {
return ""
}
h := hex.EncodeToString(b)
parts := make([]string, 0, len(b))
for i := 0; i < len(h); i += 2 {
parts = append(parts, h[i:i+2])
}
return strings.Join(parts, ":")
// Report summarises one discovery.
type Report struct {
Profile string // resolved profile name now stored
ProfileMissing string // the profile the device asked for when it no longer exists
Partial bool
Tables []TableReport // "interfaces", "entPhysicalSerialNum" and every profile table
Matched, New, Unretired, Split, Retired int
Reviews []int64 // identity_reviews opened by this run
HardwareReplaced bool
Warnings []string // discovery-owned warnings now on the device
}

View File

@@ -0,0 +1,419 @@
package discovery
import (
"slices"
"sort"
"github.com/v10networks/nmon/internal/store"
)
// Interface identity matching.
//
// The matcher is a pure function: known interfaces (active and retired) and
// the rows the device reported go in, decisions come out. It never touches the
// store. The rules, in order:
//
// 1. A reported port is looked up by ifName; if that finds nothing, by
// ifDescr plus ifPhysAddress. A row matching on all three (name, descr,
// MAC) is an exact match and wins over looser evidence.
// 2. The match must be one-to-one. A port with two candidates, or a candidate
// wanted by two ports, is ambiguous. So is a port whose name points at one
// row while its descr+MAC (or a MAC no other row has) points at another:
// that is the signature of a stack-member swap.
// 3. Ambiguous ports are never merged. Every candidate row is kept untouched
// (not updated, not retired), each ambiguous port gets a new row, and one
// Group per connected set of ports and candidates is returned for an
// identity review.
// 4. A port that matches nothing, but has the same ifIndex and MAC as an
// active row that nothing else claims, looks like a rename (an OS upgrade
// that renames ports). That is ambiguous too: the old row is kept, the
// port gets a new row, and a review is opened. (A rename that leaves ifDescr
// and MAC alone is a clean ifDescr+MAC match, as the spec says.)
// 5. A port that matches nothing else is new.
// 6. Active rows nothing claimed are retired. A retired row is only offered
// to ports no active row matched; a clean match un-retires it.
//
// Rows in Frozen (candidates of a still-open review) take no part: they are
// neither matched nor retired until an admin resolves the review.
// Match actions.
const (
ActionMatched = "matched" // clean match: row updated, ID kept
ActionNew = "new" // no candidate: new ID
ActionUnretired = "unretired" // clean match with a retired row: un-retired, ID kept
ActionSplit = "split" // ambiguous: new ID, candidates kept, review needed
)
// Review reasons recorded in a Group.
const (
ReasonMultipleCandidates = "multiple_candidates" // two candidates for one port
ReasonSharedCandidate = "shared_candidate" // one candidate wanted by two ports
ReasonNameConflict = "name_conflict" // the name says one row, descr+MAC or MAC says another
ReasonRenamed = "renamed" // same ifIndex and MAC, new name and descr
)
// MatchOptions tunes Match.
type MatchOptions struct {
// Frozen holds IDs of rows in an open ambiguous_match review.
Frozen map[string]bool
// NewID returns a fresh interface ID.
NewID func() string
}
// Outcome is the decision for one reported port.
type Outcome struct {
Found int // index into the reported slice
Action string // Action* constant
ID string // the ID the port now has
Existing *store.Interface // the row it matched; nil for new and split
Group int // index into Result.Groups, -1 unless split
}
// Group is one ambiguity: ports and candidate rows that cannot be paired.
type Group struct {
Reasons []string
Found []int // indexes into the reported slice
Candidates []store.Interface
}
// MatchResult is what Match decided.
type MatchResult struct {
Outcomes []Outcome // one per reported port, in order
Retire []store.Interface
Groups []Group
}
// Match pairs reported ports with known interfaces.
func Match(existing []store.Interface, found []Iface, o MatchOptions) MatchResult {
active, retired := splitKnown(existing, o.Frozen)
m := &matcher{
found: found,
active: active,
claimed: map[string]bool{},
res: MatchResult{Outcomes: make([]Outcome, len(found))},
}
all := make([]int, len(found))
for i := range all {
all[i] = i
m.res.Outcomes[i] = Outcome{Found: i, Group: -1}
}
ap := runPass(found, all, active)
m.record(ap, active, true)
rest := ap.none
if len(rest) > 0 && len(retired) > 0 {
rp := runPass(found, rest, retired)
m.record(rp, retired, false)
rest = rp.none
}
m.detectRenames(rest)
m.assignIDs(o.NewID)
m.retireUnclaimed()
return m.res
}
// splitKnown separates the known rows into active and retired, dropping frozen ones.
func splitKnown(existing []store.Interface, frozen map[string]bool) (active, retired []store.Interface) {
for _, e := range existing {
switch {
case frozen[e.ID]:
case e.RetiredAt != nil:
retired = append(retired, e)
default:
active = append(active, e)
}
}
return active, retired
}
// matcher is the working state of one Match call.
type matcher struct {
found []Iface
active []store.Interface
claimed map[string]bool // active row IDs paired or held by a group
res MatchResult
}
// record writes a pass's pairs and groups into the outcomes. mark says whether
// the pool is the active one, whose rows must be claimed so they are not retired.
func (m *matcher) record(p passResult, pool []store.Interface, mark bool) {
for f, r := range p.pair {
e := pool[r]
act := ActionMatched
if e.RetiredAt != nil {
act = ActionUnretired
}
m.res.Outcomes[f].Action, m.res.Outcomes[f].ID, m.res.Outcomes[f].Existing = act, e.ID, &e
if mark {
m.claimed[e.ID] = true
}
}
for _, g := range p.groups {
m.res.addGroup(g, pool, mark, m.claimed)
}
}
// detectRenames turns unmatched ports that share ifIndex and MAC with an
// unclaimed active row into review groups (rule 4).
func (m *matcher) detectRenames(rest []int) {
var renames []rawGroup
for _, f := range rest {
if m.found[f].PhysAddr == "" {
continue
}
var rows []int
for r, e := range m.active {
if !m.claimed[e.ID] && e.IfIndex == m.found[f].IfIndex && e.IfPhysAddr == m.found[f].PhysAddr {
rows = append(rows, r)
}
}
if len(rows) > 0 {
renames = append(renames, rawGroup{found: []int{f}, rows: rows, reasons: []string{ReasonRenamed}})
}
}
for _, g := range renames {
m.res.addGroup(g, m.active, true, m.claimed)
}
}
// assignIDs marks unmatched ports new and gives new and split ports a fresh ID.
func (m *matcher) assignIDs(newID func() string) {
for i := range m.res.Outcomes {
oc := &m.res.Outcomes[i]
if oc.Action == "" {
oc.Action = ActionNew
}
if oc.Action == ActionNew || oc.Action == ActionSplit {
oc.ID = newID()
}
}
}
// retireUnclaimed retires the active rows nothing claimed.
func (m *matcher) retireUnclaimed() {
for _, e := range m.active {
if !m.claimed[e.ID] {
m.res.Retire = append(m.res.Retire, e)
}
}
}
func (res *MatchResult) addGroup(g rawGroup, pool []store.Interface, mark bool, claimed map[string]bool) {
gi := len(res.Groups)
grp := Group{Reasons: g.reasons, Found: slices.Clone(g.found)}
for _, r := range g.rows {
grp.Candidates = append(grp.Candidates, pool[r])
if mark {
claimed[pool[r].ID] = true
}
}
res.Groups = append(res.Groups, grp)
for _, f := range g.found {
res.Outcomes[f].Action, res.Outcomes[f].Group = ActionSplit, gi
}
}
type rawGroup struct {
found []int
rows []int
reasons []string
}
type passResult struct {
pair map[int]int // reported index -> pool index
groups []rawGroup
none []int
}
// evidence lists the pool rows (by index) that agree with a reported port,
// per kind of evidence.
type evidence struct {
exact []int // name and descr+MAC agree
name []int // ifName agrees
descr []int // ifDescr and MAC agree
uniMAC []int // the MAC agrees and no other pool row has it
}
// gatherEvidence compares port f with every pool row.
func gatherEvidence(f Iface, pool []store.Interface, macCount map[string]int) evidence {
var ev evidence
for k, e := range pool {
nameEq := f.Name != "" && e.IfName == f.Name
dmEq := f.Descr != "" && e.IfDescr == f.Descr && e.IfPhysAddr == f.PhysAddr
if nameEq {
ev.name = append(ev.name, k)
}
if dmEq {
ev.descr = append(ev.descr, k)
}
if nameEq && dmEq {
ev.exact = append(ev.exact, k)
}
if f.PhysAddr != "" && e.IfPhysAddr == f.PhysAddr && macCount[f.PhysAddr] == 1 {
ev.uniMAC = append(ev.uniMAC, k)
}
}
return ev
}
// candidates returns the pool rows a reported port may be, and whether the
// evidence disagrees with itself.
func candidates(f Iface, pool []store.Interface, macCount map[string]int) (rows []int, conflict bool) {
ev := gatherEvidence(f, pool, macCount)
if len(ev.exact) == 1 {
return ev.exact, false
}
switch {
case len(ev.name) > 0:
return nameCandidates(ev)
case len(ev.descr) > 0:
return ev.descr, false
}
return nil, false
}
// nameCandidates resolves a port whose name matched: when descr+MAC or a
// unique MAC points at a row the name does not, every row involved is a
// candidate and the evidence conflicts (a stack-member swap).
func nameCandidates(ev evidence) (rows []int, conflict bool) {
for _, k := range slices.Concat(ev.descr, ev.uniMAC) {
if !slices.Contains(ev.name, k) {
all := slices.Concat(ev.name, ev.descr, ev.uniMAC)
slices.Sort(all)
return slices.Compact(all), true
}
}
return ev.name, false
}
// unionFind groups ports and pool rows that share candidates. Ports are
// 0..len(found)-1 and rows are len(found)+r.
type unionFind map[int]int
func (u unionFind) find(x int) int {
if p, ok := u[x]; ok && p != x {
u[x] = u.find(p)
return u[x]
}
u[x] = x
return x
}
func (u unionFind) union(a, b int) { u[u.find(a)] = u.find(b) }
// pass is the working state of one runPass call.
type pass struct {
found []Iface
cs map[int][]int // candidate rows per port
conflict map[int]bool
claims map[int]int // ports wanting each row
order []int // ports that have candidates, in order
pr passResult
uf unionFind
reasons map[int]map[string]bool // by port
}
func macCounts(pool []store.Interface) map[string]int {
macCount := map[string]int{}
for _, e := range pool {
if e.IfPhysAddr != "" {
macCount[e.IfPhysAddr]++
}
}
return macCount
}
// runPass matches the ports idxs against pool: clean pairs, ambiguous groups
// and ports with no candidate at all.
func runPass(found []Iface, idxs []int, pool []store.Interface) passResult {
p := &pass{
found: found, cs: map[int][]int{}, conflict: map[int]bool{}, claims: map[int]int{},
pr: passResult{pair: map[int]int{}}, uf: unionFind{}, reasons: map[int]map[string]bool{},
}
if len(pool) == 0 {
p.pr.none = slices.Clone(idxs)
return p.pr
}
p.gather(idxs, pool)
p.pairOrUnion()
p.buildGroups()
return p.pr
}
// gather finds each port's candidates and counts how many ports want each row.
func (p *pass) gather(idxs []int, pool []store.Interface) {
macCount := macCounts(pool)
for _, f := range idxs {
rows, c := candidates(p.found[f], pool, macCount)
if len(rows) == 0 {
p.pr.none = append(p.pr.none, f)
continue
}
p.cs[f], p.conflict[f] = rows, c
p.order = append(p.order, f)
for _, r := range rows {
p.claims[r]++
}
}
}
// pairOrUnion pairs every port with exactly one uncontested candidate and
// links the rest with the rows they want, noting why each is ambiguous.
func (p *pass) pairOrUnion() {
for _, f := range p.order {
rows := p.cs[f]
if len(rows) == 1 && !p.conflict[f] && p.claims[rows[0]] == 1 {
p.pr.pair[f] = rows[0]
continue
}
rs := map[string]bool{}
switch {
case p.conflict[f]:
rs[ReasonNameConflict] = true
case len(rows) > 1:
rs[ReasonMultipleCandidates] = true
}
for _, r := range rows {
if p.claims[r] > 1 {
rs[ReasonSharedCandidate] = true
}
p.uf.union(f, len(p.found)+r)
}
p.reasons[f] = rs
}
}
// buildGroups collects the unpaired ports into one rawGroup per connected set.
func (p *pass) buildGroups() {
byRoot := map[int]*rawGroup{}
var roots []int
for _, f := range p.order {
if _, ok := p.pr.pair[f]; ok {
continue
}
root := p.uf.find(f)
g := byRoot[root]
if g == nil {
g = &rawGroup{}
byRoot[root] = g
roots = append(roots, root)
}
g.found = append(g.found, f)
for _, r := range p.cs[f] {
if !slices.Contains(g.rows, r) {
g.rows = append(g.rows, r)
}
}
for k := range p.reasons[f] {
if !slices.Contains(g.reasons, k) {
g.reasons = append(g.reasons, k)
}
}
}
for _, root := range roots {
g := byRoot[root]
slices.Sort(g.rows)
sort.Strings(g.reasons)
p.pr.groups = append(p.pr.groups, *g)
}
}

View File

@@ -0,0 +1,217 @@
package discovery
import (
"fmt"
"slices"
"testing"
"time"
"github.com/v10networks/nmon/internal/store"
)
func row(id string, idx int64, name, descr, mac string) store.Interface {
return store.Interface{ID: id, IfIndex: idx, IfName: name, IfDescr: descr, IfPhysAddr: mac}
}
func port(idx int64, name, descr, mac string) Iface {
return Iface{IfIndex: idx, Name: name, Descr: descr, PhysAddr: mac}
}
func newIDs() func() string {
n := 0
return func() string { n++; return fmt.Sprintf("new%d", n) }
}
func run(existing []store.Interface, found []Iface, frozen ...string) MatchResult {
fz := map[string]bool{}
for _, f := range frozen {
fz[f] = true
}
return Match(existing, found, MatchOptions{Frozen: fz, NewID: newIDs()})
}
func actions(r MatchResult) []string {
var out []string
for _, o := range r.Outcomes {
out = append(out, o.Action+":"+o.ID)
}
return out
}
func wantActions(t *testing.T, r MatchResult, want ...string) {
t.Helper()
if got := actions(r); !slices.Equal(got, want) {
t.Fatalf("outcomes %v, want %v", got, want)
}
}
func retiredIDs(r MatchResult) []string {
var out []string
for _, e := range r.Retire {
out = append(out, e.ID)
}
return out
}
func TestMatchRenumberKeepsIDs(t *testing.T) {
ex := []store.Interface{row("A", 1, "ge-0/0/0", "ge-0/0/0", "m1"), row("B", 2, "ge-0/0/1", "ge-0/0/1", "m2"), row("C", 3, "ge-0/0/2", "ge-0/0/2", "m3")}
r := run(ex, []Iface{port(103, "ge-0/0/2", "ge-0/0/2", "m3"), port(102, "ge-0/0/1", "ge-0/0/1", "m2"), port(101, "ge-0/0/0", "ge-0/0/0", "m1")})
wantActions(t, r, "matched:C", "matched:B", "matched:A")
if len(r.Groups) != 0 || len(r.Retire) != 0 {
t.Fatalf("groups %v retire %v", r.Groups, r.Retire)
}
}
func TestMatchNewAndRetired(t *testing.T) {
ex := []store.Interface{row("A", 1, "a", "a", "m1"), row("B", 2, "b", "b", "m2")}
r := run(ex, []Iface{port(1, "a", "a", "m1"), port(9, "z", "z", "m9")})
wantActions(t, r, "matched:A", "new:new1")
if got := retiredIDs(r); !slices.Equal(got, []string{"B"}) {
t.Fatalf("retire %v", got)
}
}
func TestMatchByDescrAndMACWhenNameChanges(t *testing.T) {
ex := []store.Interface{row("A", 1, "Gi1", "GigabitEthernet1", "m1")}
r := run(ex, []Iface{port(1, "Gig1", "GigabitEthernet1", "m1")})
wantActions(t, r, "matched:A")
}
func TestMatchMACChangeWithSameNameIsClean(t *testing.T) {
ex := []store.Interface{row("A", 1, "ge-0/0/0", "ge-0/0/0", "m1")}
r := run(ex, []Iface{port(1, "ge-0/0/0", "ge-0/0/0", "mNEW")})
wantActions(t, r, "matched:A")
}
func TestMatchStackSwapDescrAndMACStayWithPort(t *testing.T) {
// idx and name swapped; descr and MAC stayed with the physical port.
ex := []store.Interface{row("X", 1, "Gi1/0/1", "d1", "m1"), row("Y", 2, "Gi2/0/1", "d2", "m2")}
r := run(ex, []Iface{port(2, "Gi2/0/1", "d1", "m1"), port(1, "Gi1/0/1", "d2", "m2")})
assertSwap(t, r)
}
func TestMatchStackSwapMACOnlyEvidence(t *testing.T) {
// descr mirrors the name (common), so only the MAC says the ports moved.
ex := []store.Interface{row("X", 1, "a", "a", "m1"), row("Y", 2, "b", "b", "m2")}
r := run(ex, []Iface{port(1, "a", "a", "m2"), port(2, "b", "b", "m1")})
assertSwap(t, r)
}
func assertSwap(t *testing.T, r MatchResult) {
t.Helper()
if len(r.Groups) != 1 {
t.Fatalf("%d groups, want 1: %+v", len(r.Groups), r.Groups)
}
g := r.Groups[0]
if len(g.Candidates) != 2 || len(g.Found) != 2 || !slices.Contains(g.Reasons, ReasonNameConflict) {
t.Fatalf("group %+v", g)
}
wantActions(t, r, "split:new1", "split:new2")
if len(r.Retire) != 0 {
t.Fatalf("candidates must not be retired: %v", retiredIDs(r))
}
for _, o := range r.Outcomes {
if o.Existing != nil || o.Group != 0 {
t.Errorf("outcome %+v", o)
}
}
}
func TestMatchTwoCandidatesForOnePort(t *testing.T) {
ex := []store.Interface{row("A", 1, "x", "d", "m"), row("B", 2, "x", "d2", "m2")}
r := run(ex, []Iface{port(1, "x", "dd", "mm")})
wantActions(t, r, "split:new1")
if len(r.Groups) != 1 || !slices.Contains(r.Groups[0].Reasons, ReasonMultipleCandidates) || len(r.Groups[0].Candidates) != 2 || len(r.Retire) != 0 {
t.Fatalf("%+v retire %v", r.Groups, retiredIDs(r))
}
}
func TestMatchOneCandidateForTwoPorts(t *testing.T) {
ex := []store.Interface{row("A", 1, "x", "d", "m")}
r := run(ex, []Iface{port(1, "x", "d", "m"), port(2, "x", "e", "n")})
// The first is an exact match, but the row is wanted by both by name: ambiguous.
if len(r.Groups) != 1 || !slices.Contains(r.Groups[0].Reasons, ReasonSharedCandidate) || len(r.Retire) != 0 {
t.Fatalf("%+v", r)
}
for _, o := range r.Outcomes {
if o.Action != ActionSplit {
t.Errorf("%+v", o)
}
}
}
func TestMatchRenameWithSameIfIndexAndMACIsAmbiguous(t *testing.T) {
ex := []store.Interface{row("A", 5, "Gi1/0/1", "GigabitEthernet1/0/1", "m1"), row("B", 6, "Gi1/0/2", "GigabitEthernet1/0/2", "m2")}
r := run(ex, []Iface{port(5, "Gi1/0/1", "GigabitEthernet1/0/1", "m1"), port(6, "Te1/0/2", "TenGigabitEthernet1/0/2", "m2")})
wantActions(t, r, "matched:A", "split:new1")
if len(r.Groups) != 1 || r.Groups[0].Reasons[0] != ReasonRenamed || r.Groups[0].Candidates[0].ID != "B" {
t.Fatalf("%+v", r.Groups)
}
if len(r.Retire) != 0 {
t.Fatalf("renamed row retired: %v", retiredIDs(r))
}
}
func TestMatchRenameNeedsMAC(t *testing.T) {
ex := []store.Interface{row("A", 5, "old", "old", "")}
r := run(ex, []Iface{port(5, "new", "new", "")})
wantActions(t, r, "new:new1")
if got := retiredIDs(r); !slices.Equal(got, []string{"A"}) {
t.Fatalf("retire %v", got)
}
}
func TestMatchUnretire(t *testing.T) {
gone := time.Now()
a := row("A", 1, "ge-0/0/0", "ge-0/0/0", "m1")
a.RetiredAt = &gone
r := run([]store.Interface{a}, []Iface{port(7, "ge-0/0/0", "ge-0/0/0", "m1")})
wantActions(t, r, "unretired:A")
if len(r.Retire) != 0 {
t.Fatal("retired a retired row")
}
}
func TestMatchActiveBeatsRetired(t *testing.T) {
gone := time.Now()
old := row("OLD", 1, "x", "x", "m")
old.RetiredAt = &gone
r := run([]store.Interface{old, row("NEW", 2, "x", "x", "m")}, []Iface{port(2, "x", "x", "m")})
wantActions(t, r, "matched:NEW")
}
func TestMatchFrozenRowsAreInert(t *testing.T) {
ex := []store.Interface{row("X", 1, "a", "da", "m1"), row("NX", 2, "a", "da", "m1")}
r := run(ex, []Iface{port(2, "a", "da", "m1")}, "X")
wantActions(t, r, "matched:NX")
if len(r.Retire) != 0 || len(r.Groups) != 0 {
t.Fatalf("frozen row touched: %v %v", retiredIDs(r), r.Groups)
}
}
func TestMatchExactBeatsDuplicateName(t *testing.T) {
ex := []store.Interface{row("A", 1, "x", "d", "m"), row("B", 2, "x", "e", "n")}
r := run(ex, []Iface{port(2, "x", "d", "m")})
wantActions(t, r, "matched:A")
if got := retiredIDs(r); !slices.Equal(got, []string{"B"}) {
t.Fatalf("retire %v", got)
}
}
func TestMatchSharedMACDoesNotConflict(t *testing.T) {
// SVIs and subinterfaces share the chassis MAC; a renumber of them is clean.
ex := []store.Interface{row("A", 1, "Vlan10", "Vlan10", "mc"), row("B", 2, "Vlan20", "Vlan20", "mc")}
r := run(ex, []Iface{port(2, "Vlan10", "Vlan10", "mc"), port(1, "Vlan20", "Vlan20", "mc")})
wantActions(t, r, "matched:A", "matched:B")
}
func TestMatchEmptyNamesFallBackToDescr(t *testing.T) {
ex := []store.Interface{row("A", 1, "", "eth0", "m1"), row("B", 2, "", "eth1", "m2")}
r := run(ex, []Iface{port(2, "", "eth0", "m1"), port(1, "", "eth1", "m2")})
wantActions(t, r, "matched:A", "matched:B")
}
func TestMatchFirstDiscovery(t *testing.T) {
r := run(nil, []Iface{port(1, "a", "a", "m"), port(2, "b", "b", "n")})
wantActions(t, r, "new:new1", "new:new2")
}

View File

@@ -0,0 +1,66 @@
package discovery
import (
"context"
"time"
"github.com/v10networks/nmon/internal/store"
)
// ReattachCandidate is a retired device a new one may be the same as.
type ReattachCandidate struct {
Slug string `json:"slug"`
Name string `json:"name"`
RetiredAt time.Time `json:"retired_at"`
DeviceID int64 `json:"-"`
}
// FindReattachCandidates returns the retired, unpurged devices that had any of
// addrs as a management address or the given sysName, newest retirement first.
func FindReattachCandidates(ctx context.Context, st store.Store, addrs []string, sysName string) ([]ReattachCandidate, error) {
devs, err := st.FindRetiredByAddrOrSysName(ctx, addrs, sysName)
if err != nil {
return nil, err
}
out := make([]ReattachCandidate, 0, len(devs))
for _, d := range devs {
c := ReattachCandidate{Slug: d.Slug, Name: d.Name, DeviceID: d.ID}
if d.RetiredAt != nil {
c.RetiredAt = d.RetiredAt.UTC()
}
out = append(out, c)
}
return out, nil
}
// OfferReattach opens a reattach_candidate review for each candidate that has
// none open. It is called when a client is told about the candidates.
func OfferReattach(ctx context.Context, st store.Store, cands []ReattachCandidate, name string, addrs []string, sysName string) error {
open := map[int64]bool{}
for off := int64(0); ; off += store.MaxLimit {
page, total, err := st.ListOpenIdentityReviews(ctx, store.Page{Offset: off, Limit: store.MaxLimit})
if err != nil {
return err
}
for _, r := range page {
if r.Kind == store.ReviewReattachCandidate && r.DeviceID != nil {
open[*r.DeviceID] = true
}
}
if off+store.MaxLimit >= total {
break
}
}
for _, c := range cands {
if open[c.DeviceID] {
continue
}
id := c.DeviceID
if _, err := st.CreateIdentityReview(ctx, &id, store.ReviewReattachCandidate,
[]ReattachCandidate{c}, map[string]any{"name": name, "addresses": addrs, "sys_name": sysName}); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,266 @@
package discovery
import (
"context"
"log/slog"
"math/rand/v2"
"sync"
"time"
"github.com/v10networks/nmon/internal/clock"
)
// Scheduler defaults.
const (
DefaultInterval = 12 * time.Hour
DefaultWorkers = 8
DefaultTick = time.Minute
DefaultRetryAfter = 15 * time.Minute
)
// SchedulerConfig configures a Scheduler.
type SchedulerConfig struct {
Clock clock.Clock // default clock.Real()
// Devices lists the IDs of devices to schedule. It is called every Tick, so
// added and retired devices are picked up without notification.
Devices func(ctx context.Context) ([]int64, error)
// Run discovers one device; Deps.DiscoverID fits.
Run func(ctx context.Context, deviceID int64) error
// OnDone is called after each successful run, e.g. to make the poller reload its targets.
OnDone func(deviceID int64)
Interval time.Duration // default 12 h
Workers int // default 8
Tick time.Duration // how often due devices are looked for; default 1 min
RetryAfter time.Duration // next attempt after a failed run; default 15 min
// Jitter is the half-width of the random spread added to Interval after a
// run; default Interval/10. A device first seen is due at a uniform random
// moment in [0, Interval) so a restart does not discover everything at once.
Jitter time.Duration
// Rand returns a uniform number in [0, n); default math/rand/v2. Tests inject theirs.
Rand func(n int64) int64
Logger *slog.Logger
}
// Scheduler runs discovery for every device every Interval (jittered), on
// demand through Trigger, with a bounded worker pool and at most one run per
// device at a time.
type Scheduler struct {
cfg SchedulerConfig
mu sync.Mutex
cond *sync.Cond
due map[int64]time.Time
queue []int64
state map[int64]*devState
closed bool
started bool
}
type devState struct {
queued bool // in queue
running bool
again bool // triggered while running: run once more afterwards
}
// NewScheduler returns a stopped Scheduler; call Start.
func NewScheduler(cfg SchedulerConfig) *Scheduler {
if cfg.Clock == nil {
cfg.Clock = clock.Real()
}
if cfg.Interval <= 0 {
cfg.Interval = DefaultInterval
}
if cfg.Workers <= 0 {
cfg.Workers = DefaultWorkers
}
if cfg.Tick <= 0 {
cfg.Tick = DefaultTick
}
if cfg.RetryAfter <= 0 {
cfg.RetryAfter = DefaultRetryAfter
}
if cfg.Jitter <= 0 {
cfg.Jitter = cfg.Interval / 10
}
if cfg.Rand == nil {
cfg.Rand = rand.Int64N
}
if cfg.Logger == nil {
cfg.Logger = slog.Default()
}
s := &Scheduler{cfg: cfg, due: map[int64]time.Time{}, state: map[int64]*devState{}}
s.cond = sync.NewCond(&s.mu)
return s
}
// Start launches the workers and the schedule loop. They stop when ctx is done;
// waiting on the returned group waits for them (and for any run in flight).
func (s *Scheduler) Start(ctx context.Context) *sync.WaitGroup {
s.mu.Lock()
if s.started {
s.mu.Unlock()
panic("discovery: Scheduler started twice")
}
s.started = true
s.mu.Unlock()
var wg sync.WaitGroup
for range s.cfg.Workers {
wg.Add(1)
go func() { defer wg.Done(); s.worker(ctx) }()
}
wg.Add(1)
go func() { defer wg.Done(); s.loop(ctx) }()
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done()
s.mu.Lock()
s.closed = true
s.mu.Unlock()
s.cond.Broadcast()
}()
return &wg
}
// Trigger asks for a discovery of deviceID as soon as a worker is free (a
// coldStart trap, `nmon discover`, the discover endpoint). Triggers coalesce: a
// device already queued is not queued twice, and one triggered while running
// runs exactly once more afterwards, so the walk always sees state newer than the trigger.
func (s *Scheduler) Trigger(deviceID int64) {
s.mu.Lock()
defer s.mu.Unlock()
s.enqueueLocked(deviceID)
}
func (s *Scheduler) enqueueLocked(id int64) {
st := s.state[id]
if st == nil {
st = &devState{}
s.state[id] = st
}
switch {
case st.queued:
case st.running:
st.again = true
default:
st.queued = true
s.queue = append(s.queue, id)
s.cond.Signal()
}
}
// Idle reports whether no discovery is queued or running.
func (s *Scheduler) Idle() bool {
s.mu.Lock()
defer s.mu.Unlock()
for _, st := range s.state {
if st.queued || st.running {
return false
}
}
return true
}
// Due reports when deviceID is next due, if the scheduler knows it.
func (s *Scheduler) Due(deviceID int64) (time.Time, bool) {
s.mu.Lock()
defer s.mu.Unlock()
t, ok := s.due[deviceID]
return t, ok
}
func (s *Scheduler) next(after time.Time) time.Time {
j := int64(s.cfg.Jitter)
return after.Add(s.cfg.Interval + time.Duration(s.cfg.Rand(2*j+1)-j))
}
// loop enqueues devices when they come due.
func (s *Scheduler) loop(ctx context.Context) {
for {
s.scan(ctx)
select {
case <-ctx.Done():
return
case <-s.cfg.Clock.After(s.cfg.Tick):
}
}
}
func (s *Scheduler) scan(ctx context.Context) {
ids, err := s.cfg.Devices(ctx)
if err != nil {
if ctx.Err() == nil {
s.cfg.Logger.Error("discovery schedule: list devices", "err", err)
}
return
}
now := s.cfg.Clock.Now()
s.mu.Lock()
defer s.mu.Unlock()
known := make(map[int64]bool, len(ids))
for _, id := range ids {
known[id] = true
d, ok := s.due[id]
if !ok {
d = now.Add(time.Duration(s.cfg.Rand(int64(s.cfg.Interval))))
s.due[id] = d
}
if !now.Before(d) {
// Push the due time out now so a long run is not requeued every tick;
// the worker sets the real next time when it finishes.
s.due[id] = s.next(now)
s.enqueueLocked(id)
}
}
for id := range s.due {
if !known[id] {
delete(s.due, id)
}
}
}
func (s *Scheduler) worker(ctx context.Context) {
for {
s.mu.Lock()
for len(s.queue) == 0 && !s.closed {
s.cond.Wait()
}
if s.closed {
s.mu.Unlock()
return
}
id := s.queue[0]
s.queue = s.queue[1:]
st := s.state[id]
st.queued, st.running = false, true
s.mu.Unlock()
err := s.cfg.Run(ctx, id)
s.mu.Lock()
st.running = false
if _, tracked := s.due[id]; tracked {
if err != nil {
s.due[id] = s.cfg.Clock.Now().Add(s.cfg.RetryAfter)
} else {
s.due[id] = s.next(s.cfg.Clock.Now())
}
}
if st.again && !s.closed {
st.again = false
st.queued = true
s.queue = append(s.queue, id)
s.cond.Signal()
}
s.mu.Unlock()
switch {
case err != nil:
if ctx.Err() == nil {
s.cfg.Logger.Warn("discovery failed", "device_id", id, "err", err)
}
case s.cfg.OnDone != nil:
s.cfg.OnDone(id)
}
}
}

View File

@@ -0,0 +1,275 @@
package discovery_test
import (
"context"
"errors"
"io"
"log/slog"
"math/rand/v2"
"sync"
"sync/atomic"
"testing"
"time"
"github.com/v10networks/nmon/internal/clock"
"github.com/v10networks/nmon/internal/discovery"
)
func eventually(t *testing.T, what string, cond func() bool) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for !cond() {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(time.Millisecond)
}
}
type runLog struct {
mu sync.Mutex
runs map[int64][]time.Time
}
func (l *runLog) add(id int64, at time.Time) {
l.mu.Lock()
defer l.mu.Unlock()
if l.runs == nil {
l.runs = map[int64][]time.Time{}
}
l.runs[id] = append(l.runs[id], at)
}
func (l *runLog) get(id int64) []time.Time {
l.mu.Lock()
defer l.mu.Unlock()
return append([]time.Time(nil), l.runs[id]...)
}
func TestSchedulerJitterWithinInterval(t *testing.T) {
const (
interval = 12 * time.Hour
tick = time.Hour
jitter = interval / 10
)
start := time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC)
clk := clock.NewFake(start)
var ids []int64
for i := int64(1); i <= 30; i++ {
ids = append(ids, i)
}
log := &runLog{}
rng := rand.New(rand.NewPCG(7, 11))
var mu sync.Mutex
s := discovery.NewScheduler(discovery.SchedulerConfig{
Logger: quietLogger(),
Clock: clk, Interval: interval, Tick: tick, Jitter: jitter, Workers: 4,
Devices: func(context.Context) ([]int64, error) { return ids, nil },
Run: func(_ context.Context, id int64) error { log.add(id, clk.Now()); return nil },
Rand: func(n int64) int64 { mu.Lock(); defer mu.Unlock(); return rng.Int64N(n) },
})
ctx, cancel := context.WithCancel(context.Background())
wg := s.Start(ctx)
defer func() { cancel(); wg.Wait() }()
total := func() int {
n := 0
for _, id := range ids {
n += len(log.get(id))
}
return n
}
for range 40 { // 40 h
eventually(t, "schedule loop armed", func() bool { return clk.Waiters() == 1 })
eventually(t, "runs drained", s.Idle)
clk.Advance(tick)
}
eventually(t, "runs drained", s.Idle)
if total() < 2*len(ids) {
t.Fatalf("%d runs in 40 h for %d devices", total(), len(ids))
}
spread := map[time.Duration]bool{}
for _, id := range ids {
runs := log.get(id)
if len(runs) < 2 {
t.Fatalf("device %d ran %d times in 40 h", id, len(runs))
}
first := runs[0].Sub(start)
if first < 0 || first > interval+tick {
t.Errorf("device %d first run at +%s, want within one interval (+ one tick)", id, first)
}
spread[first] = true
for i := 1; i < len(runs); i++ {
gap := runs[i].Sub(runs[i-1])
if gap < interval-jitter || gap >= interval+jitter+tick {
t.Errorf("device %d gap %s outside %s ± %s (+ tick)", id, gap, interval, jitter)
}
}
}
if len(spread) < 5 {
t.Errorf("first runs not spread out: %d distinct offsets for %d devices", len(spread), len(ids))
}
}
func TestSchedulerForgetsRetiredDevices(t *testing.T) {
clk := clock.NewFake(time.Now())
var mu sync.Mutex
ids := []int64{1}
s := discovery.NewScheduler(discovery.SchedulerConfig{
Logger: quietLogger(),
Clock: clk, Tick: time.Hour,
Devices: func(context.Context) ([]int64, error) { mu.Lock(); defer mu.Unlock(); return ids, nil },
Run: func(context.Context, int64) error { return nil },
})
ctx, cancel := context.WithCancel(context.Background())
wg := s.Start(ctx)
defer func() { cancel(); wg.Wait() }()
eventually(t, "device tracked", func() bool { _, ok := s.Due(1); return ok })
mu.Lock()
ids = nil
mu.Unlock()
eventually(t, "loop armed", func() bool { return clk.Waiters() == 1 })
clk.Advance(time.Hour)
eventually(t, "device forgotten", func() bool { _, ok := s.Due(1); return !ok })
}
func TestSchedulerTriggerCoalescesAndSingleFlights(t *testing.T) {
clk := clock.NewFake(time.Now())
var (
mu sync.Mutex
running = map[int64]int{}
maxRun = map[int64]int{}
count = map[int64]int{}
release = map[int64]chan struct{}{1: make(chan struct{}), 2: make(chan struct{}), 3: make(chan struct{}, 8)}
started = make(chan int64, 16)
done atomic.Int32
)
s := discovery.NewScheduler(discovery.SchedulerConfig{
Logger: quietLogger(),
Clock: clk, Workers: 2, Tick: time.Hour,
Devices: func(context.Context) ([]int64, error) { return nil, nil },
Run: func(_ context.Context, id int64) error {
mu.Lock()
running[id]++
maxRun[id] = max(maxRun[id], running[id])
count[id]++
mu.Unlock()
started <- id
<-release[id]
mu.Lock()
running[id]--
mu.Unlock()
return nil
},
OnDone: func(int64) { done.Add(1) },
})
ctx, cancel := context.WithCancel(context.Background())
wg := s.Start(ctx)
defer func() { cancel(); wg.Wait() }()
runs := func(id int64) int { mu.Lock(); defer mu.Unlock(); return count[id] }
s.Trigger(1)
if id := <-started; id != 1 {
t.Fatalf("started %d", id)
}
// While device 1 runs: three more triggers coalesce into exactly one rerun
// and never run it concurrently.
s.Trigger(1)
s.Trigger(1)
s.Trigger(1)
// Both workers busy soon: 2 occupies the second; 3 must queue (once, however often triggered).
s.Trigger(2)
if id := <-started; id != 2 {
t.Fatalf("started %d", id)
}
s.Trigger(3)
s.Trigger(3)
s.Trigger(3)
time.Sleep(20 * time.Millisecond)
if runs(3) != 0 || runs(1) != 1 {
t.Fatalf("runs: 1=%d 3=%d while both workers busy", runs(1), runs(3))
}
release[2] <- struct{}{} // frees a worker: device 3 starts (queued earlier than 1's rerun? it was queued after, so 1's rerun waits for 1 to finish)
if id := <-started; id != 3 {
t.Fatalf("started %d, want 3", id)
}
release[3] <- struct{}{}
release[1] <- struct{}{} // first run of 1 ends: its single rerun starts
if id := <-started; id != 1 {
t.Fatalf("started %d, want the rerun of 1", id)
}
release[1] <- struct{}{}
eventually(t, "all done", func() bool { return done.Load() == 4 })
time.Sleep(20 * time.Millisecond)
for id, want := range map[int64]int{1: 2, 2: 1, 3: 1} {
if got := runs(id); got != want {
t.Errorf("device %d ran %d times, want %d", id, got, want)
}
}
mu.Lock()
defer mu.Unlock()
for id, m := range maxRun {
if m != 1 {
t.Errorf("device %d ran %d at once", id, m)
}
}
}
func TestSchedulerFailureRetriesSoonerAndSkipsOnDone(t *testing.T) {
start := time.Date(2026, 10, 22, 0, 0, 0, 0, time.UTC)
clk := clock.NewFake(start)
var calls, dones atomic.Int32
s := discovery.NewScheduler(discovery.SchedulerConfig{
Logger: quietLogger(),
Clock: clk, Interval: 12 * time.Hour, RetryAfter: 15 * time.Minute, Tick: time.Hour,
Devices: func(context.Context) ([]int64, error) { return []int64{1}, nil },
Run: func(context.Context, int64) error { calls.Add(1); return errors.New("unreachable") },
OnDone: func(int64) { dones.Add(1) },
Rand: func(int64) int64 { return 0 }, // due immediately
})
ctx, cancel := context.WithCancel(context.Background())
wg := s.Start(ctx)
defer func() { cancel(); wg.Wait() }()
eventually(t, "first attempt", func() bool { return calls.Load() == 1 })
eventually(t, "retry due set", func() bool {
d, ok := s.Due(1)
return ok && d.Sub(clk.Now()) == 15*time.Minute
})
if dones.Load() != 0 {
t.Error("OnDone after a failure")
}
}
func TestSchedulerOnDoneAfterTrigger(t *testing.T) {
clk := clock.NewFake(time.Now())
got := make(chan int64, 1)
s := discovery.NewScheduler(discovery.SchedulerConfig{
Logger: quietLogger(),
Clock: clk,
Devices: func(context.Context) ([]int64, error) { return nil, nil },
Run: func(context.Context, int64) error { return nil },
OnDone: func(id int64) { got <- id },
})
ctx, cancel := context.WithCancel(context.Background())
wg := s.Start(ctx)
defer func() { cancel(); wg.Wait() }()
s.Trigger(42)
select {
case id := <-got:
if id != 42 {
t.Fatalf("OnDone(%d)", id)
}
case <-time.After(5 * time.Second):
t.Fatal("no OnDone")
}
}
// quietLogger discards logs unless the tests run with -v.
func quietLogger() *slog.Logger {
if testing.Verbose() {
return slog.Default()
}
return slog.New(slog.NewTextHandler(io.Discard, nil))
}

View File

@@ -0,0 +1,154 @@
package discovery
import (
"slices"
"strings"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/snmp"
"github.com/v10networks/nmon/internal/store"
)
// defaultLabel names the label column of a discovery table no metric uses.
var defaultLabel = map[string]string{
"ENTITY-MIB::entPhysicalTable": "entPhysicalName",
"HOST-RESOURCES-MIB::hrStorageTable": "hrStorageDescr",
"LLDP-MIB::lldpRemTable": "lldpRemSysName",
}
// tableSpec says how to walk one components table.
type tableSpec struct {
key string
labelRoot string // column supplying the label; "" labels rows with their index
restrict []string // metric columns; rows must exist in at least one of them
prefix string // index_prefix: keep rows under it and strip it
skip string // why the table is not walked
}
// tableSpecs lists what to walk for a profile: every table a table metric
// needs, then every discovery table no metric covers.
func tableSpecs(r *profile.Resolved) []*tableSpec {
var specs []*tableSpec
byKey := map[string]*tableSpec{}
for _, m := range r.TableMetrics() {
key := m.TableKey()
sp := byKey[key]
if sp == nil {
sp = &tableSpec{key: key, prefix: m.IndexPrefix}
if root, ok := columnOID(r, m.IndexLabel.Column); ok && root != m.OID {
sp.labelRoot = root
} else if !ok {
sp.skip = "unknown label column " + m.IndexLabel.Column
}
byKey[key] = sp
specs = append(specs, sp)
}
if !slices.Contains(sp.restrict, m.OID) {
sp.restrict = append(sp.restrict, m.OID)
}
}
for _, name := range r.DiscoveryTables {
if _, ok := byKey[name]; ok {
continue
}
sp := &tableSpec{key: name}
if root, ok := columnOID(r, defaultLabel[name]); ok {
sp.labelRoot = root
} else {
sp.skip = "no label column known for this table"
}
byKey[name] = sp
specs = append(specs, sp)
}
return specs
}
// columnOID resolves a column name to its OID: a known symbol, else a metric of the profile.
func columnOID(r *profile.Resolved, name string) (string, bool) {
if name == "" {
return "", false
}
if s, ok := profile.LookupSymbol(name); ok {
return s.OID, true
}
if m, ok := r.Metric(name); ok {
return m.OID, true
}
return "", false
}
// stripPrefix removes the spec's index prefix; ok is false for rows outside it.
func (sp *tableSpec) stripPrefix(idx string) (string, bool) {
if sp.prefix == "" {
return idx, true
}
return strings.CutPrefix(idx, sp.prefix+".")
}
// walkLabels reads the label column into index -> label.
func (sp *tableSpec) walkLabels(w *walker) (map[string]string, error) {
labels := map[string]string{}
if sp.labelRoot == "" {
return labels, nil
}
err := w.column(sp.labelRoot, func(idx string, vb snmp.Varbind) {
if i, ok := sp.stripPrefix(idx); ok {
labels[i] = text(vb)
}
})
return labels, err
}
// walkPresent reads the metric columns and returns the indices any of them has.
func (sp *tableSpec) walkPresent(w *walker) (map[string]bool, error) {
present := map[string]bool{}
for _, root := range sp.restrict {
err := w.column(root, func(idx string, _ snmp.Varbind) {
if i, ok := sp.stripPrefix(idx); ok {
present[i] = true
}
})
if err != nil {
return nil, err
}
}
return present, nil
}
// restrictRows keeps the present indices that have a label; with no label
// column the index itself is the label.
func (sp *tableSpec) restrictRows(labels map[string]string, present map[string]bool) map[string]string {
rows := map[string]string{}
for i := range present {
l, ok := labels[i]
if sp.labelRoot == "" {
rows[i] = i
} else if ok {
rows[i] = l
}
}
return rows
}
// walk returns the table's rows. Rows are the label column's rows, limited to
// those present in a metric column when there are metrics, so the poller is
// only given indices that can answer.
func (sp *tableSpec) walk(w *walker) ([]store.Component, error) {
rows, err := sp.walkLabels(w)
if err != nil {
return nil, err
}
if len(sp.restrict) > 0 {
present, err := sp.walkPresent(w)
if err != nil {
return nil, err
}
rows = sp.restrictRows(rows, present)
}
out := make([]store.Component, 0, len(rows))
for idx, l := range rows {
out = append(out, store.Component{Idx: idx, Label: l})
}
slices.SortFunc(out, func(a, b store.Component) int { return compareIdx(a.Idx, b.Idx) })
return out, nil
}

View File

@@ -0,0 +1,220 @@
package fixturegen
import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
)
// Recording is one .snmprec of a fixture. Stem is the file name without the
// extension: "juniper-junos", or "renumber.a".
type Recording struct {
Stem string
Rec *simagent.Recording
}
// Fixture is a named set of recordings and, for behaviour fixtures, the sidecar
// `<Name>.agent.yaml` that says how to serve them.
type Fixture struct {
Name string
Recordings []Recording
Agent *simagent.AgentFile // nil: v2c, public, no faults
}
// AgentYAML renders the sidecar.
func (f Fixture) AgentYAML() ([]byte, error) {
if f.Agent == nil {
return nil, nil
}
b, err := yaml.Marshal(f.Agent)
if err != nil {
return nil, err
}
return append([]byte("# Generated by testdata/gen; do not edit.\n"), b...), nil
}
// All builds every fixture, deterministically. profiles must hold the shipped profiles.
func All(profiles *profile.Set) ([]Fixture, error) {
out, err := profileFixtures(profiles)
if err != nil {
return nil, err
}
out = append(out, uptimeFixtures()...)
out = append(out, identityFixtures()...)
out = append(out, behaviourFixtures()...)
gre, err := greFixture(profiles)
if err != nil {
return nil, err
}
out = append(out, gre)
for i := range out {
if a := out[i].Agent; a != nil && a.Recording == "" {
a.Recording = out[i].Recordings[0].Stem
}
}
return out, nil
}
// profileFixtures is one fixture per shipped profile, built from the profile's own metrics.
func profileFixtures(profiles *profile.Set) ([]Fixture, error) {
var out []Fixture
for _, s := range ProfileSpecs {
res, ok := profiles.Lookup(s.Profile)
if !ok {
return nil, fmt.Errorf("fixturegen: no profile %q", s.Profile)
}
rec, err := FromProfile(res, s)
if err != nil {
return nil, err
}
out = append(out, Fixture{Name: s.Profile, Recordings: []Recording{{s.Profile, rec}}})
}
return out, nil
}
// uptimeFixtures cover advancing counters and the two ways uptime goes backwards.
func uptimeFixtures() []Fixture {
generic := Options{Ports: 8, SysName: "generic", HCCounters: true}
counters := generic
counters.SysName, counters.Counters = "counters", true
wrap := counters
wrap.SysName = "uptime-wrap"
wrap.SysUpTime = WrapIn(time.Minute) // sysUpTime rolls over a minute in while hrSystemUptime keeps climbing
wrap.HRSystemUptime = &simagent.Dynamic{Rate: 100, Initial: 864_000_000}
reset := counters
reset.SysName = "uptime-reset"
reset.SysUpTime = ResetAfter(time.Minute) // a reboot: both uptimes restart
reset.HRSystemUptime = ResetAfter(time.Minute)
var out []Fixture
for _, o := range []Options{counters, wrap, reset} {
out = append(out, Fixture{Name: o.SysName, Recordings: []Recording{{o.SysName, Generic(o)}}, Agent: &simagent.AgentFile{}})
}
return out
}
// identityFixtures are devices whose second recording changes what identifies them.
func identityFixtures() []Fixture {
// renumber: the same device after a reboot with shuffled ifIndexes.
ra := Generic(Options{Ports: 8, SysName: "renumber", HCCounters: true})
rb := Remap(ra, func(i int) int { return i * 3 % 11 })
// stackswap: after a stack-member replacement ge-0/0/0 and ge-0/0/1 trade ifName and ifIndex-by-name.
sa := Generic(Options{Ports: 8, SysName: "stackswap", HCCounters: true})
sb := SwapNames(sa, 1, 2)
return []Fixture{
{Name: "renumber", Recordings: []Recording{{"renumber.a", ra}, {"renumber.b", rb}},
Agent: &simagent.AgentFile{Recording: "renumber.a"}},
// rma: a replaced chassis (new serial), same interfaces and configuration.
{Name: "rma", Recordings: []Recording{
{"rma.a", Generic(Options{Ports: 6, SysName: "rma", HCCounters: true, Serial: "SN-OLD-0001"})},
{"rma.b", Generic(Options{Ports: 6, SysName: "rma", HCCounters: true, Serial: "SN-NEW-0002"})},
}, Agent: &simagent.AgentFile{Recording: "rma.a"}},
{Name: "stackswap", Recordings: []Recording{{"stackswap.a", sa}, {"stackswap.b", sb}},
Agent: &simagent.AgentFile{Recording: "stackswap.a"}},
}
}
// behaviourFixtures are generic devices that misbehave on the wire (slow, flaky,
// tooBig) or speak another SNMP version.
func behaviourFixtures() []Fixture {
var out []Fixture
for _, b := range []struct {
name string
agent simagent.AgentFile
}{
{"slow", simagent.AgentFile{Latency: "3s"}},
{"flaky", simagent.AgentFile{DropEvery: 3}},
{"toobig", simagent.AgentFile{TooBigAbove: 20}},
} {
a := b.agent
rec := Generic(Options{Ports: 8, SysName: b.name, HCCounters: true, Counters: true})
out = append(out, Fixture{Name: b.name, Recordings: []Recording{{b.name, rec}}, Agent: &a})
}
return append(out,
// v1: no HC counters and a 1 Gbit/s port, so the counters are 32-bit on a fast link.
Fixture{
Name: "v1",
Recordings: []Recording{{"v1", Generic(Options{Ports: 4, SysName: "v1", Counters: true})}},
Agent: &simagent.AgentFile{Version: "v1", Community: "nmon-v1"},
},
// v3: the generic walk over authPriv.
Fixture{
Name: "v3",
Recordings: []Recording{{"v3", Generic(Options{Ports: 8, SysName: "v3", HCCounters: true, Counters: true})}},
Agent: &simagent.AgentFile{Version: "v3", V3: &simagent.V3Config{
User: "nmon", AuthProto: "SHA256", AuthPass: "authpass-123", PrivProto: "AES", PrivPass: "privpass-123",
}},
})
}
// greFixture is a Juniper with a GRE tunnel, a VLAN subinterface and a loopback.
func greFixture(profiles *profile.Set) (Fixture, error) {
js, _ := SpecFor("juniper-junos")
js.SysName, js.Ports = "gre", 4
jres, ok := profiles.Lookup("juniper-junos")
if !ok {
return Fixture{}, errors.New(`fixturegen: no profile "juniper-junos"`)
}
grec, err := FromProfile(jres, js)
if err != nil {
return Fixture{}, err
}
return Fixture{Name: "gre", Recordings: []Recording{{"gre", grec}}, Agent: &simagent.AgentFile{}}, nil
}
const (
ifT = "1.3.6.1.2.1.2.2.1."
ifX = "1.3.6.1.2.1.31.1.1.1."
)
func ifRow(oid string) (col string, idx int) {
for _, p := range []string{ifT, ifX} {
if rest, ok := strings.CutPrefix(oid, p); ok {
c, i, _ := strings.Cut(rest, ".")
n, _ := strconv.Atoi(i)
return p + c, n
}
}
return "", -1
}
// Remap renumbers every interface: ifIndex i becomes f(i).
func Remap(rec *simagent.Recording, f func(int) int) *simagent.Recording {
var out []simagent.Record
for _, r := range rec.Records() {
if col, i := ifRow(r.OID); i >= 0 {
r.OID = col + "." + strconv.Itoa(f(i))
if col == ifT+"1" {
r.Value = f(i)
}
}
out = append(out, r)
}
return simagent.NewRecording(out)
}
// SwapNames exchanges the ifName of interfaces a and b (by ifIndex) and leaves
// their descr, MAC and alias in place, as after a stack-member replacement.
func SwapNames(rec *simagent.Recording, a, b int) *simagent.Recording {
names := map[int][]byte{}
for _, r := range rec.Records() {
if col, i := ifRow(r.OID); col == ifX+"1" && (i == a || i == b) {
names[i], _ = r.Value.([]byte)
}
}
var out []simagent.Record
for _, r := range rec.Records() {
if col, i := ifRow(r.OID); col == ifX+"1" && (i == a || i == b) {
r.Value = names[a+b-i]
}
out = append(out, r)
}
return simagent.NewRecording(out)
}

View File

@@ -15,12 +15,20 @@ import (
// Options shapes a Generic device. Zero values take the defaults noted.
type Options struct {
Ports int // interfaces; default 8. With 4 or more, the last two are a GRE tunnel (ifType 131) and a VLAN subinterface
// Ports is the number of interfaces; default 8. With 4 or more, the last two
// are a GRE tunnel (ifType 131) and a VLAN subinterface.
Ports int
SysName string // default "generic"
SysObjectID string // default net-snmp's 1.3.6.1.4.1.8072.3.2.10
SysDescr string // default "nmon generic fixture"
HCCounters bool // include ifHCInOctets and ifHCOutOctets
Speed uint64 // bits per second of physical ports; default 1e9
// NamePattern formats physical port names from the 0-based port number; default "ge-0/0/%d".
NamePattern string
TunnelName string // default "gr-0/0/0"
VLANName string // default NamePattern(0) + ".100"
Loopback string // when set, a software loopback interface of this name is added after the ports
Serial string // when set, ENTITY-MIB reports a chassis (entity 1) with this serial number
Counters bool // octet counters advance at 1-100 Mbit/s per port and sysUpTime advances
// SysUpTime and HRSystemUptime override the uptime objects. Nil means
// static, or advancing at 100 ticks/s when Counters is set.
@@ -48,6 +56,31 @@ func idx(prefix string, i int) string { return prefix + "." + strconv.Itoa(i) }
// Generic builds a recording with the system group, ifTable and ifXTable,
// hrProcessorLoad for two CPUs, hrStorageTable with RAM and one disk, and UCD memory.
func Generic(o Options) *simagent.Recording {
o = o.withDefaults()
b := &builder{recs: make([]simagent.Record, 0, 64+o.Ports*24)}
h := fnv.New32a()
_, _ = h.Write([]byte(o.SysName)) // hash.Hash.Write never returns an error
mac := h.Sum32()
b.system(o)
ports := o.portList()
b.ifTable(o, ports, mac)
b.hostResources(o)
b.ifXTable(o, ports)
if o.Serial != "" {
const ent = "1.3.6.1.2.1.47.1.1.1.1"
b.add(ent+".5.1", gosnmp.Integer, 3)
b.add(ent+".7.1", gosnmp.OctetString, []byte("Chassis"))
b.add(ent+".11.1", gosnmp.OctetString, []byte(o.Serial))
}
// UCD-SNMP memory (kB)
b.add("1.3.6.1.4.1.2021.4.5.0", gosnmp.Integer, 8388608)
b.add("1.3.6.1.4.1.2021.4.6.0", gosnmp.Integer, 6291456)
return simagent.NewRecording(b.recs)
}
// withDefaults fills the zero values documented on Options.
func (o Options) withDefaults() Options {
if o.Ports <= 0 {
o.Ports = 8
}
@@ -60,102 +93,135 @@ func Generic(o Options) *simagent.Recording {
if o.SysDescr == "" {
o.SysDescr = "nmon generic fixture"
}
if o.NamePattern == "" {
o.NamePattern = "ge-0/0/%d"
}
if o.TunnelName == "" {
o.TunnelName = "gr-0/0/0"
}
if o.VLANName == "" {
o.VLANName = fmt.Sprintf(o.NamePattern, 0) + ".100"
}
if o.Speed == 0 {
o.Speed = 1_000_000_000
}
b := &builder{recs: make([]simagent.Record, 0, 64+o.Ports*24)}
h := fnv.New32a()
h.Write([]byte(o.SysName))
mac := h.Sum32()
return o
}
// uptime adds an uptime object: o's override d, else advancing when Counters is set, else static.
func (b *builder) uptime(o Options, oid string, d *simagent.Dynamic) {
switch {
case d != nil:
b.dyn(oid, gosnmp.TimeTicks, d)
case o.Counters:
b.dyn(oid, gosnmp.TimeTicks, &simagent.Dynamic{Rate: 100, Initial: sysUpTimeStart})
default:
b.add(oid, gosnmp.TimeTicks, uint32(sysUpTimeStart))
}
}
// system adds the SNMPv2-MIB system group.
func (b *builder) system(o Options) {
const sys = "1.3.6.1.2.1.1"
b.add(sys+".1.0", gosnmp.OctetString, []byte(o.SysDescr))
b.add(sys+".2.0", gosnmp.ObjectIdentifier, o.SysObjectID)
uptime := func(oid string, d *simagent.Dynamic) {
switch {
case d != nil:
b.dyn(oid, gosnmp.TimeTicks, d)
case o.Counters:
b.dyn(oid, gosnmp.TimeTicks, &simagent.Dynamic{Rate: 100, Initial: sysUpTimeStart})
default:
b.add(oid, gosnmp.TimeTicks, uint32(sysUpTimeStart))
}
}
uptime(sys+".3.0", o.SysUpTime)
b.uptime(o, sys+".3.0", o.SysUpTime)
b.add(sys+".4.0", gosnmp.OctetString, []byte("noc@example.net"))
b.add(sys+".5.0", gosnmp.OctetString, []byte(o.SysName))
b.add(sys+".6.0", gosnmp.OctetString, []byte("lab"))
b.add(sys+".7.0", gosnmp.Integer, 78)
}
// ifTable
type port struct {
name, alias string
typ int
speed uint64
oper int
}
ports := make([]port, o.Ports)
// ifPort is one interface row.
type ifPort struct {
name, alias string
typ int
speed uint64
oper int
}
// portList lays out the interfaces: physical ports, then (with 4 or more) a
// GRE tunnel and a VLAN subinterface in place of the last two, then an
// optional loopback.
func (o Options) portList() []ifPort {
ports := make([]ifPort, o.Ports)
for i := range ports {
n := i + 1
ports[i] = port{name: fmt.Sprintf("ge-0/0/%d", i), alias: "port " + strconv.Itoa(n), typ: 6, speed: o.Speed, oper: 1}
ports[i] = ifPort{name: fmt.Sprintf(o.NamePattern, i), alias: "port " + strconv.Itoa(i+1), typ: 6, speed: o.Speed, oper: 1}
}
if o.Ports >= 4 {
ports[2].oper = 2 // admin up, oper down
ports[o.Ports-2] = port{name: "gr-0/0/0", alias: "GRE to scrubbing provider", typ: tunnelType, oper: 1}
ports[o.Ports-1] = port{name: "ge-0/0/0.100", alias: "VLAN 100 customer", typ: vlanType, speed: o.Speed, oper: 1}
ports[o.Ports-2] = ifPort{name: o.TunnelName, alias: "GRE to scrubbing provider", typ: tunnelType, oper: 1}
ports[o.Ports-1] = ifPort{name: o.VLANName, alias: "VLAN 100 customer", typ: vlanType, speed: o.Speed, oper: 1}
}
const ifn = "1.3.6.1.2.1.2"
b.add(ifn+".1.0", gosnmp.Integer, o.Ports)
const ift = ifn + ".2.1"
each := func(col string, t gosnmp.Asn1BER, f func(i int, p port) any) {
for i, p := range ports {
b.add(idx(ift+"."+col, i+1), t, f(i, p))
if o.Loopback != "" {
ports = append(ports, ifPort{name: o.Loopback, alias: "loopback", typ: 24, speed: 0, oper: 1})
}
return ports
}
// column adds one value per port under prefix.col.
func (b *builder) column(prefix, col string, ports []ifPort, t gosnmp.Asn1BER, f func(i int, p ifPort) any) {
for i, p := range ports {
b.add(idx(prefix+"."+col, i+1), t, f(i, p))
}
}
// octetCounter adds an octet counter column: static, or advancing at 1-100
// Mbit/s per port when Counters is set. hc selects the ifXTable 64-bit column.
func (b *builder) octetCounter(o Options, nPorts int, col string, t gosnmp.Asn1BER, hc bool, dirSalt int) {
const ift = "1.3.6.1.2.1.2.2.1"
for i := 0; i < nPorts; i++ {
oid := idx(ift+"."+col, i+1)
if hc {
oid = idx("1.3.6.1.2.1.31.1.1.1."+col, i+1)
}
}
// Octet counters: dynamic when Counters is set.
counter := func(col string, t gosnmp.Asn1BER, hc bool, dirSalt int) {
for i := range ports {
oid := idx(ift+"."+col, i+1)
if !o.Counters {
v := uint64(i+1) * 1_000_000 * uint64(dirSalt)
if hc {
oid = idx("1.3.6.1.2.1.31.1.1.1."+col, i+1)
b.add(oid, t, v*1000)
} else {
b.add(oid, t, uint32(v))
}
if !o.Counters {
v := uint64(i+1) * 1_000_000 * uint64(dirSalt)
if hc {
b.add(oid, t, v*1000)
} else {
b.add(oid, t, uint32(v))
}
continue
}
mbps := uint64(1 + ((i+1)*(30+7*dirSalt))%100)
d := &simagent.Dynamic{Rate: float64(mbps * 125_000), Initial: uint64(i+1) * 1_000_000_000}
b.dyn(oid, t, d)
continue
}
mbps := uint64(1 + ((i+1)*(30+7*dirSalt))%100)
b.dyn(oid, t, &simagent.Dynamic{Rate: float64(mbps * 125_000), Initial: uint64(i+1) * 1_000_000_000})
}
each("1", gosnmp.Integer, func(i int, _ port) any { return i + 1 })
each("2", gosnmp.OctetString, func(_ int, p port) any { return []byte(p.name) })
each("3", gosnmp.Integer, func(_ int, p port) any { return p.typ })
each("4", gosnmp.Integer, func(int, port) any { return 1500 })
each("5", gosnmp.Gauge32, func(_ int, p port) any { return uint32(min(p.speed, 1<<32-1)) })
each("6", gosnmp.OctetString, func(i int, p port) any {
if p.typ == tunnelType {
}
// ifTable adds IF-MIB::ifNumber and the ifTable columns.
func (b *builder) ifTable(o Options, ports []ifPort, mac uint32) {
const ifn = "1.3.6.1.2.1.2"
const ift = ifn + ".2.1"
b.add(ifn+".1.0", gosnmp.Integer, len(ports))
zero := func(int, ifPort) any { return uint32(0) }
b.column(ift, "1", ports, gosnmp.Integer, func(i int, _ ifPort) any { return i + 1 })
b.column(ift, "2", ports, gosnmp.OctetString, func(_ int, p ifPort) any { return []byte(p.name) })
b.column(ift, "3", ports, gosnmp.Integer, func(_ int, p ifPort) any { return p.typ })
b.column(ift, "4", ports, gosnmp.Integer, func(int, ifPort) any { return 1500 })
b.column(ift, "5", ports, gosnmp.Gauge32, func(_ int, p ifPort) any { return uint32(min(p.speed, 1<<32-1)) })
b.column(ift, "6", ports, gosnmp.OctetString, func(i int, p ifPort) any {
if p.typ == tunnelType || p.typ == 24 {
return []byte{}
}
return []byte{0x00, 0x50, 0x56, byte(mac >> 8), byte(mac), byte(i + 1)}
})
each("7", gosnmp.Integer, func(int, port) any { return 1 })
each("8", gosnmp.Integer, func(_ int, p port) any { return p.oper })
counter("10", gosnmp.Counter32, false, 1)
each("13", gosnmp.Counter32, func(int, port) any { return uint32(0) })
each("14", gosnmp.Counter32, func(int, port) any { return uint32(0) })
counter("16", gosnmp.Counter32, false, 2)
each("19", gosnmp.Counter32, func(int, port) any { return uint32(0) })
each("20", gosnmp.Counter32, func(int, port) any { return uint32(0) })
b.column(ift, "7", ports, gosnmp.Integer, func(int, ifPort) any { return 1 })
b.column(ift, "8", ports, gosnmp.Integer, func(_ int, p ifPort) any { return p.oper })
b.octetCounter(o, len(ports), "10", gosnmp.Counter32, false, 1)
b.column(ift, "13", ports, gosnmp.Counter32, zero)
b.column(ift, "14", ports, gosnmp.Counter32, zero)
b.octetCounter(o, len(ports), "16", gosnmp.Counter32, false, 2)
b.column(ift, "19", ports, gosnmp.Counter32, zero)
b.column(ift, "20", ports, gosnmp.Counter32, zero)
}
// HOST-RESOURCES (25) sorts between ifTable (2) and ifXTable (31)
// hostResources adds HOST-RESOURCES-MIB: uptime, hrStorageTable (RAM and one
// disk), the processor table and hrProcessorLoad for two CPUs. (OID 25 sorts
// between ifTable (2) and ifXTable (31).)
func (b *builder) hostResources(o Options) {
const hr = "1.3.6.1.2.1.25"
uptime(hr+".1.1.0", o.HRSystemUptime)
b.uptime(o, hr+".1.1.0", o.HRSystemUptime)
const hrs = hr + ".2.3.1"
storage := []struct {
idx int
@@ -205,26 +271,18 @@ func Generic(o Options) *simagent.Recording {
for i, c := range cpus {
b.add(idx(hr+".3.3.1.2", c), gosnmp.Integer, loads[i])
}
}
// ifXTable
// ifXTable adds ifName, the optional 64-bit octet counters, ifHighSpeed and ifAlias.
func (b *builder) ifXTable(o Options, ports []ifPort) {
const ifx = "1.3.6.1.2.1.31.1.1.1"
eachX := func(col string, t gosnmp.Asn1BER, f func(i int, p port) any) {
for i, p := range ports {
b.add(idx(ifx+"."+col, i+1), t, f(i, p))
}
}
eachX("1", gosnmp.OctetString, func(_ int, p port) any { return []byte(p.name) })
b.column(ifx, "1", ports, gosnmp.OctetString, func(_ int, p ifPort) any { return []byte(p.name) })
if o.HCCounters {
counter("6", gosnmp.Counter64, true, 1)
counter("10", gosnmp.Counter64, true, 2)
b.octetCounter(o, len(ports), "6", gosnmp.Counter64, true, 1)
b.octetCounter(o, len(ports), "10", gosnmp.Counter64, true, 2)
}
eachX("15", gosnmp.Gauge32, func(_ int, p port) any { return uint32(p.speed / 1_000_000) })
eachX("18", gosnmp.OctetString, func(_ int, p port) any { return []byte(p.alias) })
// UCD-SNMP memory (kB)
b.add("1.3.6.1.4.1.2021.4.5.0", gosnmp.Integer, 8388608)
b.add("1.3.6.1.4.1.2021.4.6.0", gosnmp.Integer, 6291456)
return simagent.NewRecording(b.recs)
b.column(ifx, "15", ports, gosnmp.Gauge32, func(_ int, p ifPort) any { return uint32(p.speed / 1_000_000) })
b.column(ifx, "18", ports, gosnmp.OctetString, func(_ int, p ifPort) any { return []byte(p.alias) })
}
// ResetAfter returns an uptime Dynamic that advances at 100 ticks/s and

View File

@@ -0,0 +1,30 @@
package fixturegen
import (
"strings"
"testing"
"github.com/v10networks/nmon/internal/profile"
)
func TestNoFixtureEmitsNotAccessibleColumns(t *testing.T) {
set, err := profile.Load("")
if err != nil {
t.Fatal(err)
}
all, err := All(set)
if err != nil {
t.Fatal(err)
}
for _, f := range all {
for _, r := range f.Recordings {
for _, rec := range r.Rec.Records() {
for col := range notAccessible {
if strings.HasPrefix(rec.OID, col+".") {
t.Errorf("%s emits not-accessible column %s (%s)", r.Stem, col, rec.OID)
}
}
}
}
}
}

View File

@@ -0,0 +1,415 @@
package fixturegen
import (
"fmt"
"math"
"sort"
"strconv"
"strings"
"github.com/gosnmp/gosnmp"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/simagent"
)
// ProfileSpec says how a device that a shipped profile matches presents itself.
type ProfileSpec struct {
Profile string
SysName string
SysObjectID string
SysDescr string
Ports int
NamePattern string
TunnelName string
Loopback string
}
// ProfileSpecs has one entry per shipped profile.
var ProfileSpecs = []ProfileSpec{
{
Profile: "generic",
SysName: "generic",
SysObjectID: "1.3.6.1.4.1.8072.3.2.255",
SysDescr: "nmon generic fixture",
NamePattern: "ge-0/0/%d",
Loopback: "lo",
},
{
Profile: "juniper-junos",
SysName: "mx204-lab",
SysObjectID: "1.3.6.1.4.1.2636.1.1.1.2.21",
SysDescr: "Juniper Networks, Inc. mx204 internet router, kernel JUNOS 21.4R3.15, Build date: 2022-09-21",
NamePattern: "ge-0/0/%d",
TunnelName: "gr-0/0/0",
Loopback: "lo0",
},
{
Profile: "cisco-ios",
SysName: "c2960x-lab",
SysObjectID: "1.3.6.1.4.1.9.1.1745",
SysDescr: "Cisco IOS Software, C2960X Software (C2960X-UNIVERSALK9-M), Version 15.2(7)E3, RELEASE SOFTWARE (fc3)",
NamePattern: "GigabitEthernet1/0/%d",
TunnelName: "Tunnel0",
Loopback: "Loopback0",
},
{
Profile: "cisco-nxos",
SysName: "n9k-lab",
SysObjectID: "1.3.6.1.4.1.9.12.3.1.3.1",
SysDescr: "Cisco NX-OS(tm) nxos.9.3.8.bin, Software (nxos), Version 9.3(8), RELEASE SOFTWARE",
NamePattern: "Ethernet1/%d",
TunnelName: "Tunnel1",
Loopback: "loopback0",
},
{
Profile: "arista-eos",
SysName: "dcs7050-lab",
SysObjectID: "1.3.6.1.4.1.30065.1.3011.7050",
SysDescr: "Arista Networks EOS version 4.28.3M running on an Arista Networks DCS-7050TX-64",
NamePattern: "Ethernet%d",
TunnelName: "Tunnel1",
Loopback: "Loopback0",
},
{
Profile: "mikrotik-routeros",
SysName: "rb750-lab",
SysObjectID: "1.3.6.1.4.1.14988.1",
SysDescr: "RouterOS RB750Gr3",
NamePattern: "ether%d",
TunnelName: "gre-tunnel1",
Loopback: "lo",
},
{
Profile: "ubiquiti-edgeos",
SysName: "er-x-lab",
SysObjectID: "1.3.6.1.4.1.8072.3.2.10",
SysDescr: "EdgeRouter X 5-Port v2.0.9-hotfix.2.5574651.220617.0658",
NamePattern: "eth%d",
TunnelName: "tun0",
Loopback: "lo",
},
{
Profile: "fortigate",
SysName: "fgt60f-lab",
SysObjectID: "1.3.6.1.4.1.12356.101.1.1",
SysDescr: "FortiGate-60F v7.2.4,build1396,230123 (GA)",
NamePattern: "port%d",
TunnelName: "ssl.root",
Loopback: "lo",
},
{
Profile: "pfsense",
SysName: "pfsense-lab",
SysObjectID: "1.3.6.1.4.1.8072.3.2.8",
SysDescr: "pfSense pfsense.example.net 2.7.2-RELEASE pfSense FreeBSD 14.0-CURRENT amd64",
NamePattern: "igb%d",
TunnelName: "gre0",
Loopback: "lo0",
},
{
Profile: "linux-netsnmp",
SysName: "linux-lab",
SysObjectID: "1.3.6.1.4.1.8072.3.2.10",
SysDescr: "Linux linux-lab 6.1.0-18-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.1.76-1 (2024-02-01) x86_64",
NamePattern: "eth%d",
TunnelName: "gre0",
Loopback: "lo",
},
{
Profile: "windows-snmp",
SysName: "win-lab",
SysObjectID: "1.3.6.1.4.1.311.1.1.3.1.2",
SysDescr: "Hardware: Intel64 Family 6 Model 85 Stepping 7 AT/AT COMPATIBLE - " +
"Software: Windows Version 6.3 (Build 17763 Multiprocessor Free)",
Ports: 4,
NamePattern: "Ethernet %d",
TunnelName: "isatap.{ABC}",
Loopback: "Software Loopback Interface 1",
},
{
Profile: "apc-ups",
SysName: "ups-lab",
SysObjectID: "1.3.6.1.4.1.318.1.3.27",
SysDescr: "APC Web/SNMP Management Card " +
"(MB:v4.1.0 PF:v6.9.6 PN:apc_hw05_aos_696.bin AF1:v6.9.6 AN1:apc_hw05_rpdu2g_696.bin MN:AP7921B HR:05)",
Ports: 1,
NamePattern: "eth%d",
Loopback: "lo",
},
{
Profile: "printer",
SysName: "printer-lab",
SysObjectID: "1.3.6.1.4.1.11.2.3.9.1",
SysDescr: "HP ETHERNET MULTI-ENVIRONMENT,ROM none,JETDIRECT,JD149,EEPROM JSI23200012,CIDATE 08/16/2019",
Ports: 1,
NamePattern: "eth%d",
Loopback: "lo",
},
}
// SpecFor returns the spec of a shipped profile.
func SpecFor(name string) (ProfileSpec, bool) {
for _, s := range ProfileSpecs {
if s.Profile == name {
return s, true
}
}
return ProfileSpec{}, false
}
// Options converts the spec to Generic options (without the profile's metrics).
func (s ProfileSpec) Options() Options {
ports := s.Ports
if ports == 0 {
ports = 8
}
return Options{
Ports: ports, SysName: s.SysName, SysObjectID: s.SysObjectID, SysDescr: s.SysDescr, HCCounters: true,
NamePattern: s.NamePattern, TunnelName: s.TunnelName, Loopback: s.Loopback,
Serial: "SN-" + strings.ToUpper(s.Profile) + "-0001",
}
}
const (
entTable = "ENTITY-MIB::entPhysicalTable"
entName = "1.3.6.1.2.1.47.1.1.1.1.7"
entClassCol = "1.3.6.1.2.1.47.1.1.1.1.5"
)
// notAccessible lists index columns the MIB marks not-accessible. A real
// device never returns them, so a fixture must not either.
var notAccessible = map[string]bool{
"1.3.6.1.2.1.33.1.3.3.1.1": true, // upsInputLineIndex
"1.3.6.1.2.1.33.1.4.4.1.1": true, // upsOutputLineIndex
}
// rowLabels names table rows by label column.
var rowLabels = map[string][]string{
"jnxOperatingDescr": {"Routing Engine 0", "Routing Engine 1", "FPC @ 0/*/*"},
"ciscoMemoryPoolName": {"Processor", "I/O"},
"laNames": {"Load-1", "Load-5", "Load-15"},
"prtMarkerSuppliesDescription": {"Black Toner Cartridge", "Cyan Toner Cartridge"},
"hrStorageDescr": {"Physical memory", "/"},
"entPhysicalName": {"Temperature sensor 1", "Temperature sensor 2"},
}
// plausible is the value a healthy device reports, in the metric's unit.
var plausible = map[string]float64{
"percent": 23, "celsius": 38, "volts": 12, "amps": 2, "watts": 150, "dbm": -2.5, "rpm": 4200,
"bytes": 1_000_000_000, "count": 100, "ratio": 0.5, "minutes": 45, "seconds": 86400, "hertz": 50,
"bits": 1_000_000, "octets": 1_000_000, "packets": 1000, "errors": 0, "bps": 1_000_000_000,
}
// FromProfile builds a recording from the profile's own metric list: the Generic
// system group and interface tables, then every pollable scalar, interface and
// table metric the profile names, with a plausible value of the SNMP type the
// metric declares, and the label column of every table.
func FromProfile(res *profile.Resolved, s ProfileSpec) (*simagent.Recording, error) {
pb := newProfileBuilder(res, s)
var errs []string
for _, m := range res.Metrics {
if !m.Pollable() {
continue
}
t, v := valueFor(m)
switch m.Kind() {
case profile.KindScalar:
pb.addScalar(m, t, v)
case profile.KindInterface:
pb.addInterface(m, t, v)
default:
if msg := pb.addTable(m, t, v); msg != "" {
errs = append(errs, msg)
}
}
}
if len(errs) > 0 {
sort.Strings(errs)
return nil, fmt.Errorf("fixturegen: profile %s: %s", res.Name, strings.Join(errs, "; "))
}
return simagent.NewRecording(pb.recs), nil
}
// profileBuilder accumulates records on top of the Generic base. A metric
// never overwrites an OID that is already present.
type profileBuilder struct {
res *profile.Resolved
recs []simagent.Record
have map[string]bool
nIf int // interface rows
}
func newProfileBuilder(res *profile.Resolved, s ProfileSpec) *profileBuilder {
opts := s.Options()
pb := &profileBuilder{
res: res, recs: append([]simagent.Record(nil), Generic(opts).Records()...), have: map[string]bool{}, nIf: opts.Ports,
}
if opts.Loopback != "" {
pb.nIf++
}
for _, r := range pb.recs {
pb.have[r.OID] = true
}
return pb
}
func (pb *profileBuilder) add(oid string, t gosnmp.Asn1BER, v any) {
if !pb.have[oid] {
pb.have[oid] = true
pb.recs = append(pb.recs, simagent.Record{OID: oid, Type: t, Value: v})
}
}
// underBase reports whether the base recording already has rows below prefix.
func (pb *profileBuilder) underBase(prefix string) bool {
for _, r := range pb.recs {
if strings.HasPrefix(r.OID, prefix+".") {
return true
}
}
return false
}
func (pb *profileBuilder) addScalar(m profile.Metric, t gosnmp.Asn1BER, v any) {
if strings.HasSuffix(m.OID, ".0") && !pb.have[m.OID] {
pb.add(m.OID, t, v)
}
}
func (pb *profileBuilder) addInterface(m profile.Metric, t gosnmp.Asn1BER, v any) {
if pb.underBase(m.OID) {
return
}
for i := 1; i <= pb.nIf; i++ {
pb.add(m.OID+"."+strconv.Itoa(i), t, v)
}
}
// tableRows says which row indexes to populate for a table metric.
func tableRows(m profile.Metric) []int {
switch {
case m.Table == entTable:
return []int{2, 3} // sensors; entity 1 is the chassis
case m.IndexLabel.Column == "laNames":
return []int{1, 2, 3}
case m.IndexLabel.Column == m.Name || m.Name == "prtAlertSeverityLevel":
return []int{1}
}
return []int{1, 2}
}
// addTable adds the rows of a table metric and their label column. It returns
// a problem description when the label column is unknown.
func (pb *profileBuilder) addTable(m profile.Metric, t gosnmp.Asn1BER, v any) string {
if strings.HasPrefix(m.OID, "1.3.6.1.2.1.25.") && pb.underBase(m.OID) {
return "" // Generic already provides HOST-RESOURCES rows
}
labelOID, ok := columnOID(pb.res, m.IndexLabel.Column)
if !ok {
return fmt.Sprintf("%s: label column %s is not a known symbol or metric", m.Name, m.IndexLabel.Column)
}
for _, r := range tableRows(m) {
pb.add(m.OID+"."+strconv.Itoa(r), t, v)
if labelOID != m.OID && !notAccessible[labelOID] {
pb.addLabel(m, labelOID, r)
}
}
return ""
}
// addLabel adds the label cell for row r of m's table.
func (pb *profileBuilder) addLabel(m profile.Metric, labelOID string, r int) {
row := strconv.Itoa(r)
switch sym, known := profile.LookupSymbol(m.IndexLabel.Column); {
case m.Table == entTable:
pb.add(entName+"."+row, gosnmp.OctetString, []byte(labelFor(m.IndexLabel.Column, r)))
pb.add(entClassCol+"."+row, gosnmp.Integer, 8)
case known && sym.Type != profile.Text:
pb.add(labelOID+"."+row, gosnmp.Integer, r)
default:
pb.add(labelOID+"."+row, gosnmp.OctetString, []byte(labelFor(m.IndexLabel.Column, r)))
}
}
func columnOID(r *profile.Resolved, name string) (string, bool) {
if s, ok := profile.LookupSymbol(name); ok {
return s.OID, true
}
if m, ok := r.Metric(name); ok {
return m.OID, true
}
return "", false
}
func labelFor(col string, row int) string {
if col == "entPhysicalName" {
row-- // sensors start at entity 2
}
if l := rowLabels[col]; row-1 < len(l) {
return l[row-1]
}
return fmt.Sprintf("%s %d", col, row)
}
// valueFor returns an SNMP type and value the metric's declaration accepts.
func valueFor(m profile.Metric) (gosnmp.Asn1BER, any) {
switch m.Type {
case profile.Counter64:
return gosnmp.Counter64, uint64(5_000_000_000)
case profile.Counter32:
return gosnmp.Counter32, uint32(123456)
case profile.Text:
return gosnmp.OctetString, []byte("text")
case profile.Status:
return gosnmp.Integer, statusValue(m)
}
return gaugeValue(m)
}
// statusValue is the lowest enum key, or for battery and output states the
// lowest key from 2 up (the "on battery" style state).
func statusValue(m profile.Metric) int {
keys := make([]int, 0, len(m.Enum))
for k := range m.Enum {
keys = append(keys, k)
}
sort.Ints(keys)
if len(keys) == 0 {
return 1
}
if strings.Contains(m.Name, "Battery") || strings.Contains(m.Name, "Output") {
for _, k := range keys {
if k >= 2 {
return k
}
}
}
return keys[0]
}
// gaugeValue is a plausible reading in the metric's unit, converted to the raw
// value the device would send (undoing the scale and avoiding not-available sentinels).
func gaugeValue(m profile.Metric) (gosnmp.Asn1BER, any) {
target, ok := plausible[m.Unit]
if !ok {
target = 10
}
scale := m.Scale
if scale == 0 {
scale = 1
}
raw := math.Round(target / scale)
for _, na := range m.NotAvailable {
if raw == na {
raw++
}
}
if m.Unit == "seconds" && strings.Contains(m.Name, "Remaining") {
return gosnmp.TimeTicks, uint32(raw * 100)
}
if raw < 0 || m.Unit == "dbm" || m.Unit == "celsius" {
return gosnmp.Integer, int(raw)
}
return gosnmp.Gauge32, uint32(min(raw, math.MaxUint32))
}

View File

@@ -38,12 +38,9 @@ func WriteSnmprec(w io.Writer, rec *simagent.Recording) error {
return err
}
}
bw.WriteString(r.OID)
bw.WriteByte('|')
bw.WriteString(tag)
bw.WriteByte('|')
bw.WriteString(val)
bw.WriteByte('\n')
if _, err := bw.WriteString(r.OID + "|" + tag + "|" + val + "\n"); err != nil {
return err
}
}
return bw.Flush()
}

View File

@@ -0,0 +1,226 @@
package fixtures
import (
"fmt"
"sort"
"strings"
"time"
"github.com/v10networks/nmon/internal/profiletest"
"github.com/v10networks/nmon/internal/store"
)
// failures collects the differences Compare finds.
type failures []string
func (f *failures) addf(format string, a ...any) { *f = append(*f, fmt.Sprintf(format, a...)) }
// Compare returns one line per way the run differs from e.
func (e *Expect) Compare(run *Run) []string {
r := run.Result
var bad failures
if r.Profile != e.Profile {
bad.addf("profile %q, expected %q", r.Profile, e.Profile)
}
found := e.compareInterfaces(r, &bad)
if e.Degraded != nil {
if len(r.Poll.Status) != 1 || r.Poll.Status[0].State != store.PollDegraded {
bad.addf("device not degraded within %s: %+v", e.Degraded.Within, r.Poll.Status)
}
return bad
}
polled := map[string]bool{}
for n, i := range found {
if !i.Excluded {
polled[n] = true
}
}
e.compareSeries(r, polled, &bad)
e.comparePDUBudgets(r, &bad)
e.compareCounters(run, &bad)
return bad
}
// compareInterfaces checks the discovered interfaces against e.Interfaces and
// returns the live (not retired) ones by name.
func (e *Expect) compareInterfaces(r *profiletest.Result, bad *failures) map[string]profiletest.IfaceInfo {
got := map[string]profiletest.IfaceInfo{}
for _, i := range r.Interfaces {
if !i.Retired {
got[i.Name] = i
}
}
want := map[string]ExpectIface{}
for _, i := range e.Interfaces {
want[i.IfName] = i
g, ok := got[i.IfName]
switch {
case !ok:
bad.addf("interface %s not found by discovery", i.IfName)
case g.Excluded != i.Excluded:
bad.addf("interface %s: excluded=%v, expected %v", i.IfName, g.Excluded, i.Excluded)
case g.Unreliable != i.Unreliable:
bad.addf("interface %s: unreliable=%v, expected %v", i.IfName, g.Unreliable, i.Unreliable)
}
}
for n := range got {
if _, ok := want[n]; !ok {
bad.addf("discovery found unexpected interface %s", n)
}
}
return got
}
// seriesKey identifies a metric and whether it is per interface, per component or scalar.
type seriesKey struct{ name, kind string }
func (k seriesKey) String() string {
if k.kind == "" {
return k.name
}
return k.name + "{" + k.kind + "}"
}
// parseSeries splits "<metric>{kind}" into its parts.
func parseSeries(s string) seriesKey {
name, kind := s, ""
if i := strings.IndexByte(s, '{'); i >= 0 {
name, kind = s[:i], strings.TrimSuffix(s[i+1:], "}")
}
return seriesKey{name, kind}
}
// compareSeries checks that exactly the series e.Series lists were written, and
// that each has the expected labels.
func (e *Expect) compareSeries(r *profiletest.Result, polled map[string]bool, bad *failures) {
gotKinds := map[seriesKey]map[string]bool{}
for _, s := range r.Poll.Series {
k := seriesKey{s.Name, s.Kind}
if gotKinds[k] == nil {
gotKinds[k] = map[string]bool{}
}
gotKinds[k][s.Label] = true
}
wantKinds := map[seriesKey]bool{}
for _, s := range e.Series {
k := parseSeries(s)
wantKinds[k] = true
labels, ok := gotKinds[k]
if !ok {
bad.addf("series %s not written", s)
continue
}
e.compareLabels(s, k, labels, polled, bad)
}
for k := range gotKinds {
if !wantKinds[k] {
bad.addf("unexpected series %s written", k)
}
}
}
// compareLabels checks the labels one expected series was written with.
func (e *Expect) compareLabels(s string, k seriesKey, labels, polled map[string]bool, bad *failures) {
switch k.kind {
case "iface":
if d := setDiff(labels, polled); d != "" {
bad.addf("series %s: interfaces differ from the polled set: %s", s, d)
}
case "component":
w, listed := e.Components[k.name]
if !listed {
bad.addf("series %s: no components list in expect.yaml", s)
} else if d := setDiff(labels, toSet(w)); d != "" {
bad.addf("series %s: component labels differ from expected: %s", s, d)
}
case "":
if len(labels) != 1 {
bad.addf("series %s: %d series, expected 1", s, len(labels))
}
}
}
// comparePDUBudgets checks the GET PDUs each interval group issued against max_pdus_per_cycle.
func (e *Expect) comparePDUBudgets(r *profiletest.Result, bad *failures) {
seen := map[string]bool{}
for _, g := range r.Poll.Groups {
key := durKey(g.Interval)
seen[key] = true
want, ok := e.MaxPDUs[key]
switch {
case !ok:
bad.addf("interval group %s issued %d PDUs but expect.yaml has no budget for it", key, g.PDUs)
case g.PDUs != want:
bad.addf("interval group %s: %d PDUs per cycle, expect.yaml says %d (change it on purpose if the profile changed)",
key, g.PDUs, want)
}
if g.Err != "" {
bad.addf("interval group %s failed: %s", key, g.Err)
}
}
for k := range e.MaxPDUs {
if !seen[k] {
bad.addf("expect.yaml has a budget for interval group %s but none was polled", k)
}
}
}
// compareCounters checks type mismatches, walk PDUs and the adapted request size.
func (e *Expect) compareCounters(run *Run, bad *failures) {
r := run.Result
if r.Poll.Mismatches > 0 {
bad.addf("type mismatches: %s", r.MismatchSummary())
}
if r.Poll.WalkPDUs != e.WalkPDUs {
bad.addf("%d GETBULK/GETNEXT PDUs on the poll path (nmon counter), expected %d", r.Poll.WalkPDUs, e.WalkPDUs)
}
if n := run.Poll.GetBulk + run.Poll.GetNext; n != uint64(e.WalkPDUs) {
bad.addf("agent saw %d GETBULK/GETNEXT after discovery, expected %d", n, e.WalkPDUs)
}
if e.MaxVarbindsAfter > 0 && len(r.Poll.Groups) > 0 {
// The first (hot) group meets tooBig first and halves; later groups then grow +5 on a clean cycle.
if g := r.Poll.Groups[0]; g.Final > e.MaxVarbindsAfter {
bad.addf("interval group %s settled on %d varbinds per request, expected at most %d",
durKey(g.Interval), g.Final, e.MaxVarbindsAfter)
}
}
}
func durKey(d time.Duration) string {
if d%time.Minute == 0 {
return fmt.Sprintf("%dm", d/time.Minute)
}
return d.String()
}
func toSet(l []string) map[string]bool {
m := make(map[string]bool, len(l))
for _, s := range l {
m[s] = true
}
return m
}
func setDiff(got, want map[string]bool) string {
var missing, extra []string
for k := range want {
if !got[k] {
missing = append(missing, k)
}
}
for k := range got {
if !want[k] {
extra = append(extra, k)
}
}
sort.Strings(missing)
sort.Strings(extra)
var parts []string
if len(missing) > 0 {
parts = append(parts, "missing "+strings.Join(missing, ","))
}
if len(extra) > 0 {
parts = append(parts, "unexpected "+strings.Join(extra, ","))
}
return strings.Join(parts, "; ")
}

View File

@@ -0,0 +1,303 @@
// Package fixtures runs the recordings in testdata/snmprec through a live
// simagent, discovery and one poll, and checks each against its
// <name>.expect.yaml. It is the M2 gate harness; cmd and tests share it.
package fixtures
import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
"gopkg.in/yaml.v3"
"github.com/v10networks/nmon/internal/fixturegen"
"github.com/v10networks/nmon/internal/poller"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/profiletest"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/store"
)
// Expect is a fixture's <name>.expect.yaml.
type Expect struct {
Profile string `yaml:"profile"`
Interfaces []ExpectIface `yaml:"interfaces"`
// Series lists "<metric>{iface}", "<metric>{component}" or "<metric>" (a
// scalar): exactly the series one poll must write. An {iface} metric must
// have one series per polled (not excluded) interface.
Series []string `yaml:"series"`
// Components lists the exact component labels of each {component} metric.
Components map[string][]string `yaml:"components,omitempty"`
// MaxPDUs is the GET PDUs one cycle of each interval group issues
// ("1m", "5m", "15m"). The harness fails when the count differs either way:
// a profile change that alters it must change this file on purpose.
MaxPDUs map[string]int `yaml:"max_pdus_per_cycle,omitempty"`
WalkPDUs int `yaml:"walk_pdus_on_poll_path"`
// MaxVarbindsAfter bounds the varbinds per request the poller settles on (toobig).
MaxVarbindsAfter int `yaml:"max_varbinds_after,omitempty"`
// Degraded says the device cannot be polled in time: it must be degraded within Within.
Degraded *ExpectDegraded `yaml:"degraded,omitempty"`
}
// ExpectIface is one interface discovery must find.
type ExpectIface struct {
IfName string `yaml:"ifname"`
Excluded bool `yaml:"excluded,omitempty"`
Unreliable bool `yaml:"unreliable,omitempty"`
}
// ExpectDegraded is the slow-device expectation.
type ExpectDegraded struct {
Within string `yaml:"within"`
}
// LoadExpect reads path, rejecting unknown keys.
func LoadExpect(path string) (*Expect, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
dec := yaml.NewDecoder(strings.NewReader(string(b)))
dec.KnownFields(true)
var e Expect
if err := dec.Decode(&e); err != nil {
return nil, fmt.Errorf("%s: %w", path, err)
}
return &e, nil
}
// Dir finds testdata/snmprec by walking up from the working directory.
func Dir() (string, error) {
wd, err := os.Getwd()
if err != nil {
return "", err
}
for d := wd; ; d = filepath.Dir(d) {
if _, err := os.Stat(filepath.Join(d, "go.mod")); err == nil {
return filepath.Join(d, "testdata", "snmprec"), nil
}
if filepath.Dir(d) == d {
return "", errors.New("fixtures: go.mod not found above " + wd)
}
}
}
// Names lists the fixtures that have an expect.yaml, sorted.
func Names(dir string) ([]string, error) {
files, err := filepath.Glob(filepath.Join(dir, "*.expect.yaml"))
if err != nil {
return nil, err
}
var out []string
for _, f := range files {
out = append(out, strings.TrimSuffix(filepath.Base(f), ".expect.yaml"))
}
sort.Strings(out)
return out, nil
}
// Family is the IP family the agent is reached over.
type Family string
// Families.
const (
V6 Family = "v6"
V4 Family = "v4"
)
func (f Family) listen() string {
if f == V4 {
return "127.0.0.1:0"
}
return "[::1]:0"
}
// Run is the outcome of RunFixture.
type Run struct {
Result *profiletest.Result
// Poll is what the agent counted between the end of discovery and the end of the poll.
Poll simagent.Stats
Agent *simagent.AgentFile
}
// RunFixture serves fixture name from dir on loopback and runs profiletest
// against it. A fixture whose agent is too slow to discover through (latency
// above the request timeout) is discovered through a clean twin and polled
// through the slow agent.
func RunFixture(ctx context.Context, dir, name string, fam Family, set *profile.Set, exp *Expect) (*Run, error) {
af, _, err := simagent.LoadAgentFile(filepath.Join(dir, name+".agent.yaml"))
if err != nil {
return nil, err
}
cfg, err := af.Config()
if err != nil {
return nil, err
}
cfg.Addr = fam.listen()
if cfg.Behaviour.Latency > 0 {
return runSlow(ctx, af, cfg, set, exp)
}
a, err := simagent.Start(ctx, cfg)
if err != nil {
return nil, err
}
defer func() { _ = a.Close() }() // agent is test infrastructure; nothing to lose on close
o := profiletest.Options{Addr: a.Addr(), Cred: af.Credential(), Profiles: set, AfterDiscovery: a.ResetStats}
if cfg.Behaviour.DropEvery > 0 {
o.Timeout = 200 * time.Millisecond // a dropped request costs one timeout
}
res, err := profiletest.Run(ctx, o)
if err != nil {
return nil, err
}
return &Run{Result: res, Poll: a.Stats(), Agent: af}, nil
}
func runSlow(ctx context.Context, af *simagent.AgentFile, cfg simagent.Config, set *profile.Set, exp *Expect) (*Run, error) {
clean := cfg
clean.Behaviour = simagent.Behaviour{}
ca, err := simagent.Start(ctx, clean)
if err != nil {
return nil, err
}
defer func() { _ = ca.Close() }() // as above
sa, err := simagent.Start(ctx, cfg)
if err != nil {
return nil, err
}
defer func() { _ = sa.Close() }() // as above
env, err := profiletest.NewEnv(ctx, set)
if err != nil {
return nil, err
}
defer env.Close()
dev, err := env.AddDevice(ctx, "slow", ca.Addr(), af.Credential())
if err != nil {
return nil, err
}
if _, err := env.Discover(ctx, dev); err != nil {
return nil, err
}
dev, _ = env.Store.GetDeviceByID(ctx, dev.ID)
dev.SNMPPort = int(sa.Addr().Port())
if err := env.Store.UpdateDevice(ctx, dev); err != nil {
return nil, err
}
within := 20 * time.Second
if exp != nil && exp.Degraded != nil {
if within, err = time.ParseDuration(exp.Degraded.Within); err != nil {
return nil, err
}
}
env.Timeout, env.Retries = 500*time.Millisecond, -1
pr, err := env.Poll(ctx, dev, profiletest.PollOptions{
Interval: time.Second, Timeout: within,
Settle: func(st []poller.DeviceStatus) bool {
return len(st) == 1 && st[0].State == store.PollDegraded
},
})
if err != nil {
return nil, err
}
res := &profiletest.Result{Profile: dev.Profile, Poll: pr}
ifs, _ := env.Store.ListInterfaces(ctx, dev.ID, true)
for _, i := range ifs {
res.Interfaces = append(res.Interfaces,
profiletest.IfaceInfo{Name: i.IfName, IfIndex: i.IfIndex, Excluded: i.Excluded, Unreliable: i.Unreliable})
}
return &Run{Result: res, Agent: af}, nil
}
// Draft derives an expectation from a run. It is a starting point to review,
// not an oracle.
func Draft(run *Run) *Expect {
r := run.Result
e := &Expect{Profile: r.Profile, Components: map[string][]string{}, MaxPDUs: map[string]int{}}
ifs := append([]profiletest.IfaceInfo(nil), r.Interfaces...)
sort.Slice(ifs, func(i, j int) bool { return ifs[i].IfIndex < ifs[j].IfIndex })
for _, i := range ifs {
if !i.Retired {
e.Interfaces = append(e.Interfaces, ExpectIface{IfName: i.Name, Excluded: i.Excluded, Unreliable: i.Unreliable})
}
}
if run.Agent != nil && run.Agent.Latency != "" {
e.Degraded = &ExpectDegraded{Within: "15s"}
e.Components, e.MaxPDUs = nil, nil
return e
}
e.draftSeries(r.Poll)
for _, g := range r.Poll.Groups {
e.MaxPDUs[durKey(g.Interval)] = g.PDUs
}
if run.Agent != nil && run.Agent.TooBigAbove > 0 {
e.MaxVarbindsAfter = run.Agent.TooBigAbove
}
return e
}
// draftSeries lists one series entry per metric that produced samples, with
// the component labels of each {component} metric.
func (e *Expect) draftSeries(poll *profiletest.PollResult) {
seen := map[string]bool{}
for _, m := range poll.Metrics {
if m.Samples == 0 || seen[m.Name] {
continue
}
seen[m.Name] = true
s := m.Name
if m.Kind != "" {
s += "{" + m.Kind + "}"
}
e.Series = append(e.Series, s)
if m.Kind == "component" {
var labels []string
for _, x := range poll.Series {
if x.Name == m.Name {
labels = append(labels, x.Label)
}
}
e.Components[m.Name] = labels
}
}
if len(e.Components) == 0 {
e.Components = nil
}
}
// WriteDrafts runs every fixture and writes a draft <name>.expect.yaml for it.
// An existing (reviewed) file is kept unless force is set.
func WriteDrafts(ctx context.Context, dir string, force bool) error {
set, err := profile.Load("")
if err != nil {
return err
}
all, err := fixturegen.All(set)
if err != nil {
return err
}
for _, f := range all {
out := filepath.Join(dir, f.Name+".expect.yaml")
if _, err := os.Stat(out); err == nil && !force {
continue
}
run, err := RunFixture(ctx, dir, f.Name, V6, set, nil)
if err != nil {
return fmt.Errorf("%s: %w", f.Name, err)
}
b, err := yaml.Marshal(Draft(run))
if err != nil {
return err
}
hdr := "# DRAFT from `go run ./testdata/gen -expect`: review before committing.\n" +
"# max_pdus_per_cycle is exact: a profile change that alters it fails until this file is changed on purpose.\n"
if err := os.WriteFile(out, append([]byte(hdr), b...), 0o644); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,323 @@
package fixtures_test
import (
"context"
"os"
"path/filepath"
"slices"
"testing"
"time"
"github.com/v10networks/nmon/internal/discovery"
"github.com/v10networks/nmon/internal/fixtures"
"github.com/v10networks/nmon/internal/profile"
"github.com/v10networks/nmon/internal/profiletest"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/store"
)
func shipped(t *testing.T) *profile.Set {
t.Helper()
s, err := profile.Load("")
if err != nil {
t.Fatal(err)
}
return s
}
func dir(t *testing.T) string {
t.Helper()
d, err := fixtures.Dir()
if err != nil {
t.Fatal(err)
}
return d
}
func families() []fixtures.Family {
if os.Getenv("NMON_TEST_V6ONLY") == "1" {
return []fixtures.Family{fixtures.V6}
}
return []fixtures.Family{fixtures.V6, fixtures.V4}
}
// TestFixtures runs every testdata/snmprec/*.expect.yaml over IPv6 and IPv4.
func TestFixtures(t *testing.T) {
d := dir(t)
names, err := fixtures.Names(d)
if err != nil || len(names) < 25 {
t.Fatalf("found %d fixtures with an expect.yaml: %v", len(names), err)
}
set := shipped(t)
for _, name := range names {
exp, err := fixtures.LoadExpect(filepath.Join(d, name+".expect.yaml"))
if err != nil {
t.Fatal(err)
}
t.Run(name, func(t *testing.T) {
t.Parallel()
for _, fam := range families() {
t.Run(string(fam), func(t *testing.T) {
ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second)
defer cancel()
run, err := fixtures.RunFixture(ctx, d, name, fam, set, exp)
if err != nil {
t.Fatal(err)
}
for _, msg := range exp.Compare(run) {
t.Error(msg)
}
a := run.Agent
switch {
case a.TooBigAbove > 0 && run.Poll.TooBig == 0:
t.Error("toobig agent never answered tooBig: the adaptation was not exercised")
case a.DropEvery > 0 && run.Poll.Dropped == 0:
t.Error("flaky agent dropped nothing during the poll")
case a.Version == "v3" && run.Poll.Get == 0:
t.Error("v3 poll sent no GETs")
}
})
}
})
}
}
// TestEveryProfileHasAFixture keeps the one-fixture-per-shipped-profile rule.
func TestEveryProfileHasAFixture(t *testing.T) {
d := dir(t)
for _, n := range shipped(t).Names() {
exp, err := fixtures.LoadExpect(filepath.Join(d, n+".expect.yaml"))
if err != nil {
t.Errorf("profile %s: %v", n, err)
continue
}
if exp.Profile != n {
t.Errorf("fixture %s expects profile %q", n, exp.Profile)
}
}
}
type pair struct {
t *testing.T
env *profiletest.Env
dev *store.Device
d string
name string
}
func start(t *testing.T, name string) *pair {
t.Helper()
d := dir(t)
ctx := context.Background()
env, err := profiletest.NewEnv(ctx, shipped(t))
if err != nil {
t.Fatal(err)
}
t.Cleanup(env.Close)
return &pair{t: t, env: env, d: d, name: name}
}
// discover serves recording <name>.<which> and discovers dev (created on first use).
func (p *pair) discover(stem string) (*discovery.Report, *simagent.Agent) {
p.t.Helper()
af, _, err := simagent.LoadAgentFile(filepath.Join(p.d, p.name+".agent.yaml"))
if err != nil {
p.t.Fatal(err)
}
rec, err := simagent.LoadRecording(filepath.Join(p.d, stem+".snmprec"))
if err != nil {
p.t.Fatal(err)
}
cfg, err := af.ConfigFor(rec)
if err != nil {
p.t.Fatal(err)
}
a, err := simagent.Start(context.Background(), cfg)
if err != nil {
p.t.Fatal(err)
}
p.t.Cleanup(func() { _ = a.Close() })
ctx := context.Background()
if p.dev == nil {
if p.dev, err = p.env.AddDevice(ctx, p.name, a.Addr(), af.Credential()); err != nil {
p.t.Fatal(err)
}
} else {
p.dev.SNMPPort = int(a.Addr().Port())
if err := p.env.Store.UpdateDevice(ctx, p.dev); err != nil {
p.t.Fatal(err)
}
}
rep, err := p.env.Discover(ctx, p.dev)
if err != nil {
p.t.Fatal(err)
}
return rep, a
}
func (p *pair) ifaces() map[string]store.Interface {
p.t.Helper()
list, err := p.env.Store.ListInterfaces(context.Background(), p.dev.ID, false)
if err != nil {
p.t.Fatal(err)
}
m := map[string]store.Interface{}
for _, i := range list {
m[i.IfName] = i
}
return m
}
func (p *pair) reviews(kind string) int {
p.t.Helper()
rows, _, err := p.env.Store.ListOpenIdentityReviews(context.Background(), store.Page{Limit: store.MaxLimit})
if err != nil {
p.t.Fatal(err)
}
n := 0
for _, r := range rows {
if r.Kind == kind {
n++
}
}
return n
}
// TestRenumberKeepsInterfaceIDs: the same device with shuffled ifIndexes keeps every ID.
func TestRenumberKeepsInterfaceIDs(t *testing.T) {
p := start(t, "renumber")
p.discover(p.name + ".a")
before := p.ifaces()
rep, _ := p.discover(p.name + ".b")
after := p.ifaces()
if len(before) != 8 || len(after) != 8 || rep.Matched != 8 || rep.New != 0 || rep.Retired != 0 || len(rep.Reviews) != 0 {
t.Fatalf("report %+v, %d -> %d interfaces", rep, len(before), len(after))
}
moved := 0
for n, b := range before {
a := after[n]
if a.ID != b.ID {
t.Errorf("%s: ID %s became %s", n, b.ID, a.ID)
}
if a.IfIndex != b.IfIndex {
moved++
}
}
if moved != 8 {
t.Errorf("only %d of 8 interfaces changed ifIndex: the fixture does not renumber", moved)
}
}
// TestRMAOpensHardwareReplacedReview: a new serial with the same interfaces continues the history.
func TestRMAOpensHardwareReplacedReview(t *testing.T) {
p := start(t, "rma")
p.discover(p.name + ".a")
before := p.ifaces()
rep, _ := p.discover(p.name + ".b")
if !rep.HardwareReplaced || rep.Matched != 6 || rep.New != 0 || rep.Retired != 0 {
t.Fatalf("report %+v", rep)
}
if n := p.reviews(store.ReviewHardwareReplaced); n != 1 {
t.Errorf("%d hardware_replaced reviews, want 1", n)
}
for n, i := range p.ifaces() {
if before[n].ID != i.ID {
t.Errorf("%s changed ID", n)
}
}
cur, _ := p.env.Store.GetDeviceByID(context.Background(), p.dev.ID)
if cur.Slug != p.dev.Slug || cur.HWSerial != "SN-NEW-0002" {
t.Errorf("device %s serial %q", cur.Slug, cur.HWSerial)
}
}
// TestStackSwapOpensOneReviewAndMergesNothing: two interfaces trade names.
func TestStackSwapOpensOneReviewAndMergesNothing(t *testing.T) {
p := start(t, "stackswap")
p.discover(p.name + ".a")
before := p.ifaces()
rep, _ := p.discover(p.name + ".b")
if rep.Split != 2 || len(rep.Reviews) != 1 || rep.Retired != 0 {
t.Fatalf("report %+v", rep)
}
if n := p.reviews(store.ReviewAmbiguousMatch); n != 1 {
t.Errorf("%d ambiguous_match reviews, want 1", n)
}
all, _ := p.env.Store.ListInterfaces(context.Background(), p.dev.ID, true)
ids := map[string]bool{}
for _, i := range all {
ids[i.ID] = true
}
for n, b := range before {
if !ids[b.ID] {
t.Errorf("history of %s lost", n)
}
}
if len(all) != 10 {
t.Errorf("%d interface rows, want the 8 originals + 2 new", len(all))
}
}
// TestV1DiscoversWithGetNext: v1 has no GETBULK, and a 32-bit counter on a 1 Gbit/s port is flagged.
func TestV1DiscoversWithGetNext(t *testing.T) {
d := dir(t)
af, _, err := simagent.LoadAgentFile(filepath.Join(d, "v1.agent.yaml"))
if err != nil {
t.Fatal(err)
}
cfg, err := af.Config()
if err != nil {
t.Fatal(err)
}
a, err := simagent.Start(context.Background(), cfg)
if err != nil {
t.Fatal(err)
}
defer func() { _ = a.Close() }() // test cleanup
var disc simagent.Stats
res, err := profiletest.Run(context.Background(), profiletest.Options{
Addr: a.Addr(), Cred: af.Credential(), Profiles: shipped(t), AfterDiscovery: func() { disc = a.Stats(); a.ResetStats() },
})
if err != nil {
t.Fatal(err)
}
if disc.GetNext == 0 || disc.GetBulk != 0 {
t.Errorf("v1 discovery used %d GETNEXT and %d GETBULK", disc.GetNext, disc.GetBulk)
}
if !slices.Contains(res.Warnings, "counter_32bit_fast_link") {
t.Errorf("warnings %v lack counter_32bit_fast_link", res.Warnings)
}
if i, ok := res.Interface("ge-0/0/0"); !ok || !i.Unreliable || i.HasHC || i.SpeedBPS != 1_000_000_000 {
t.Errorf("ge-0/0/0: %+v", i)
}
if st := a.Stats(); st.GetBulk+st.GetNext != 0 {
t.Errorf("poll path used %d walk PDUs", st.GetBulk+st.GetNext)
}
}
// TestV3AuthPrivWorks: the v3 fixture only answers authPriv with the right user.
func TestV3AuthPrivWorks(t *testing.T) {
d := dir(t)
af, _, _ := simagent.LoadAgentFile(filepath.Join(d, "v3.agent.yaml"))
cfg, err := af.Config()
if err != nil {
t.Fatal(err)
}
a, err := simagent.Start(context.Background(), cfg)
if err != nil {
t.Fatal(err)
}
defer func() { _ = a.Close() }() // test cleanup
good := af.Credential()
res, err := profiletest.Run(context.Background(), profiletest.Options{Addr: a.Addr(), Cred: good, Profiles: shipped(t)})
if err != nil || len(res.Interfaces) == 0 || len(res.Poll.Series) == 0 {
t.Fatalf("authPriv: %v %+v", err, res)
}
bad := good
bad.V3PrivPass = "wrong-passphrase"
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if _, err := profiletest.Run(ctx, profiletest.Options{Addr: a.Addr(), Cred: bad, Profiles: shipped(t), Timeout: 300 * time.Millisecond, Retries: -1}); err == nil {
t.Error("a wrong privacy passphrase was accepted")
}
}

View File

@@ -0,0 +1,99 @@
package fixtures_test
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/v10networks/nmon/internal/profiletest"
"github.com/v10networks/nmon/internal/simagent"
"github.com/v10networks/nmon/internal/snmp"
)
// realRecordings are curated LibreNMS tests/snmpsim recordings (never copied
// into this repo) with the profile each must match. They are the ones that
// carry an interface table; many LibreNMS recordings are partial.
var realRecordings = []struct{ file, profile string }{
{"junos_qfx5100", "juniper-junos"},
{"ios_2960x", "cisco-ios"},
{"nxos_n9k-c93180yc-fx3", "cisco-nxos"},
{"arista_eos", "arista-eos"},
{"routeros", "mikrotik-routeros"},
{"edgeos", "ubiquiti-edgeos"},
{"fortigate_60f", "fortigate"},
{"pfsense", "pfsense"},
{"linux_ntc", "linux-netsnmp"},
{"windows_svr_2019_1809", "windows-snmp"},
{"apc_smx750", "apc-ups"},
{"jetdirect_m480", "printer"},
}
// TestLibreNMSRecordings runs discovery and one poll against real device
// recordings when NMON_LIBRENMS_DIR points at a LibreNMS checkout.
func TestLibreNMSRecordings(t *testing.T) {
root := os.Getenv("NMON_LIBRENMS_DIR")
if root == "" {
t.Skip("NMON_LIBRENMS_DIR not set")
}
set := shipped(t)
list := realRecordings
// NMON_LIBRENMS_EXTRA="file:profile,file:profile" adds recordings to try.
for _, x := range strings.Split(os.Getenv("NMON_LIBRENMS_EXTRA"), ",") {
if f, p, ok := strings.Cut(x, ":"); ok {
list = append(list[:len(list):len(list)], struct{ file, profile string }{f, p})
}
}
for _, rr := range list {
t.Run(rr.file, func(t *testing.T) {
t.Parallel()
path := filepath.Join(root, "tests", "snmpsim", rr.file+".snmprec")
rec, skipped, err := simagent.LoadRecordingLenient(path)
if err != nil {
t.Fatal(err)
}
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute)
defer cancel()
a, err := simagent.Start(ctx, simagent.Config{Recording: rec, Community: "public"})
if err != nil {
t.Fatal(err)
}
defer func() { _ = a.Close() }() // test cleanup
res, err := profiletest.Run(ctx, profiletest.Options{
Addr: a.Addr(), Cred: snmp.Credential{Version: "2c", Community: "public"}, Profiles: set,
Timeout: 2 * time.Second,
})
if err != nil {
t.Fatal(err)
}
pollable, withSamples := 0, 0
for _, m := range res.Poll.Metrics {
pollable++
if m.Samples > 0 {
withSamples++
}
}
t.Logf("REAL %-24s profile=%-18s ifaces=%-3d comps=%-3d metrics=%d/%d PDUs=%d mismatches=%d skipped_lines=%d",
rr.file, res.Profile, len(res.Interfaces), res.Components, withSamples, pollable, res.Poll.TotalPDUs, res.Poll.Mismatches, skipped)
if res.Profile != rr.profile {
t.Errorf("matched profile %q, want %q (sysObjectID %s, sysDescr %.80q)", res.Profile, rr.profile, res.SysObjectID, res.SysDescr)
}
if len(res.Interfaces) == 0 {
t.Error("no interfaces found")
}
if res.Poll.Mismatches > 0 {
t.Errorf("type mismatches: %s", res.MismatchSummary())
}
if res.Poll.WalkPDUs != 0 {
t.Errorf("%d walk PDUs on the poll path", res.Poll.WalkPDUs)
}
if os.Getenv("NMON_VERBOSE") != "" {
if err := res.Print(os.Stdout); err != nil {
t.Error(err)
}
}
})
}
}

View File

@@ -97,7 +97,7 @@ func (f *family) open(p *Pinger) (*xicmp.PacketConn, error) {
}
c, err := xicmp.ListenPacket(network, laddr)
if err != nil {
f.err = fmt.Errorf("%w (%s): %v", ErrUnsupported, network, err)
f.err = fmt.Errorf("%w (%s): %w", ErrUnsupported, network, err)
return nil, f.err
}
f.conn = c

View File

@@ -15,7 +15,7 @@ import (
func ping(t *testing.T, addr string) {
t.Helper()
p := New(clock.Real())
defer p.Close()
defer func() { _ = p.Close() }()
rtt, err := p.Ping(context.Background(), netip.MustParseAddr(addr), 2*time.Second)
if errors.Is(err, ErrUnsupported) {
t.Skipf("unprivileged ICMP unavailable: %v", err)
@@ -39,7 +39,7 @@ func TestPingV4Loopback(t *testing.T) {
func TestConcurrentPings(t *testing.T) {
p := New(clock.Real())
defer p.Close()
defer func() { _ = p.Close() }()
var wg sync.WaitGroup
errs := make(chan error, 50)
for range 50 {
@@ -64,7 +64,7 @@ func TestConcurrentPings(t *testing.T) {
func TestContextCancel(t *testing.T) {
p := New(clock.NewFake(time.Unix(0, 0)))
defer p.Close()
defer func() { _ = p.Close() }()
ctx, cancel := context.WithCancel(context.Background())
cancel()
_, err := p.Ping(ctx, netip.MustParseAddr("::1"), time.Hour)

114
internal/librenms/detect.go Normal file
View File

@@ -0,0 +1,114 @@
package librenms
import (
"fmt"
"regexp"
"strings"
)
var numericOIDRE = regexp.MustCompile(`^\.?[0-9]+(\.[0-9]+)+$`)
// match is the profile match: section built from an os_detection file.
type match struct {
sysObjectID []string
sysDescr []string
notes []string // conditions that could not be carried over
}
// phpRegex converts a PHP /pattern/flags regular expression to a Go one.
func phpRegex(s string) (string, error) {
s = strings.TrimSpace(s)
if len(s) < 2 {
return "", fmt.Errorf("not delimited")
}
d := s[0]
end := strings.LastIndexByte(s, d)
if end <= 0 {
return "", fmt.Errorf("no closing delimiter")
}
pat, flags := s[1:end], s[end+1:]
prefix := ""
for _, f := range flags {
switch f {
case 'i', 's', 'm':
prefix += string(f)
case 'u', 'D', 'U':
default:
return "", fmt.Errorf("unsupported flag %q", string(f))
}
}
if prefix != "" {
pat = "(?" + prefix + ")" + pat
}
if _, err := regexp.Compile(pat); err != nil {
return "", err
}
return pat, nil
}
// parseDetection maps the discovery: list of an os_detection file.
func parseDetection(doc map[string]any) match {
var m match
for gi, g := range asList(doc["discovery"]) {
e := asMap(g)
if e == nil {
m.notes = append(m.notes, fmt.Sprintf("discovery[%d]: not a mapping, skipped", gi))
continue
}
oids, descr := parseDetectionGroup(&m, gi, e)
if len(oids) > 0 && len(descr) > 0 {
m.notes = append(m.notes, fmt.Sprintf("discovery[%d] requires sysObjectID AND sysDescr; "+
"only sysObjectID is used, so this match is broader than LibreNMS's", gi))
descr = nil
}
m.sysObjectID = appendNew(m.sysObjectID, oids...)
m.sysDescr = appendNew(m.sysDescr, descr...)
}
return m
}
// parseDetectionGroup reads one discovery entry, recording what it cannot express in m.notes.
func parseDetectionGroup(m *match, gi int, e map[string]any) (oids, descr []string) {
for _, k := range sortedKeys(e) {
switch k {
case "sysObjectID":
for _, o := range strs(e[k]) {
if !numericOIDRE.MatchString(strings.TrimSpace(o)) {
m.notes = append(m.notes, fmt.Sprintf("discovery[%d].sysObjectID %q is not a numeric OID, skipped", gi, o))
continue
}
oids = append(oids, strings.TrimPrefix(strings.TrimSpace(o), "."))
}
case "sysDescr":
for _, d := range strs(e[k]) {
descr = append(descr, regexp.QuoteMeta(d))
}
case "sysDescr_regex":
for _, d := range strs(e[k]) {
re, err := phpRegex(d)
if err != nil {
m.notes = append(m.notes, fmt.Sprintf("discovery[%d].sysDescr_regex %q skipped: %v", gi, oneLine(d), err))
continue
}
descr = append(descr, re)
}
default:
m.notes = append(m.notes, fmt.Sprintf("discovery[%d] has an extra condition %q that profile match cannot express "+
"(LibreNMS requires it in addition)", gi, k))
}
}
return oids, descr
}
func appendNew(dst []string, v ...string) []string {
outer:
for _, x := range v {
for _, y := range dst {
if x == y {
continue outer
}
}
dst = append(dst, x)
}
return dst
}

View File

@@ -0,0 +1,455 @@
package librenms
import (
"errors"
"fmt"
"regexp"
"slices"
"strconv"
"strings"
"github.com/v10networks/nmon/internal/mib"
"github.com/v10networks/nmon/internal/profile"
)
// Skip records something in the LibreNMS definition that did not become a metric.
type Skip struct {
Where string
Reason string
}
type label struct{ column, as string }
type stateVal struct {
value int
descr string
}
type metric struct {
name, oid, unit string
scale float64
notAvail []float64
label *label
notes []string
src string
states []stateVal
helper bool
}
// importer holds the state for converting one OS.
type importer struct {
l *mib.Loader
generic map[string]string // numeric OID -> metric name in the shipped generic profile
gnames map[string]bool // metric names of the generic profile
metrics []*metric
byOID map[string]*metric
names map[string]bool
skips []Skip
}
var (
indexedNumRE = regexp.MustCompile(`^((?:\.[0-9]+)+)\.\{\{\s*\$(?:index|subindex[0-9]+)\s*\}\}$`)
noindexNumRE = regexp.MustCompile(`^((?:\.[0-9]+)+)\{\{\s*\$noindex\s*\}\}$`)
plainNumRE = regexp.MustCompile(`^(?:\.[0-9]+)+$`)
refRE = regexp.MustCompile(`\{\{\s*([A-Za-z][\w-]*::[A-Za-z]\w*)(?::\d+)?\s*\}\}`)
wholeRefRE = regexp.MustCompile(`^[A-Za-z][\w-]*::[A-Za-z]\w*$`)
badNameRE = regexp.MustCompile(`[^A-Za-z0-9_]`)
)
// sensorUnits maps a LibreNMS sensor class to the profile unit vocabulary.
// A class not listed here is imported as "count" with a note.
var sensorUnits = map[string]string{
"temperature": "celsius", "voltage": "volts", "current": "amps", "power": "watts", "fanspeed": "rpm",
"humidity": "percent", "dbm": "dbm", "state": "enum", "count": "count", "load": "percent", "percent": "percent",
"frequency": "hertz", "charge": "percent", "runtime": "minutes", "power_factor": "ratio", "ber": "ratio",
"eer": "ratio", "loss": "percent", "signal": "dbm", "bitrate": "bps",
}
func (im *importer) skip(where, format string, a ...any) {
im.skips = append(im.skips, Skip{where, fmt.Sprintf(format, a...)})
}
// ref is a resolved symbolic or numeric OID.
type ref struct {
oid string
kind mib.Kind
name string
known bool // resolved through a MIB
suffix string
}
func (im *importer) resolve(sym string) (ref, error) {
sym = strings.TrimSpace(sym)
if numericOIDRE.MatchString(sym) {
return ref{oid: strings.TrimPrefix(sym, ".")}, nil
}
suffix, rest, mod := "", sym, ""
if m, r, ok := strings.Cut(sym, "::"); ok {
mod, rest = m, r
}
if i := strings.IndexByte(rest, '.'); i >= 0 {
rest, suffix = rest[:i], rest[i:]
if !plainNumRE.MatchString(suffix) {
return ref{}, fmt.Errorf("%s: index suffix %q is not numeric", sym, suffix)
}
}
name := rest
q := name
if mod != "" {
q = mod + "::" + name
}
o, err := im.l.Object(q)
if err != nil {
return ref{}, err
}
return ref{oid: o.OID + suffix, kind: o.Kind, name: o.Name, known: true, suffix: suffix}, nil
}
type target struct {
oid string
indexed bool // a table column: rows are found by walking
name string
known bool
}
// resolveTarget decides the OID a metric polls. num_oid (the explicit numeric OID)
// wins over the symbolic name; the symbol supplies the name and tells scalars from columns.
func (im *importer) resolveTarget(valueSym, numOID string) (target, error) {
var rf *ref
var rerr error
if valueSym != "" {
if r, err := im.resolve(valueSym); err == nil {
rf = &r
} else {
rerr = err
}
}
t := target{}
if rf != nil {
t.name, t.known = rf.name, rf.known
}
if numOID = strings.TrimSpace(numOID); numOID != "" {
err := numericTarget(&t, rf, numOID)
return t, err
}
if rf == nil {
if rerr == nil {
rerr = errors.New("no oid or num_oid")
}
return t, rerr
}
err := symbolicTarget(&t, rf, valueSym)
return t, err
}
// numericTarget applies an explicit num_oid, which may carry an index template.
func numericTarget(t *target, rf *ref, numOID string) error {
switch {
case indexedNumRE.MatchString(numOID):
base := strings.TrimPrefix(indexedNumRE.FindStringSubmatch(numOID)[1], ".")
if rf != nil && rf.kind == mib.KindScalar {
t.oid = base + ".0"
} else {
t.oid, t.indexed = base, true
}
case noindexNumRE.MatchString(numOID):
t.oid = strings.TrimPrefix(noindexNumRE.FindStringSubmatch(numOID)[1], ".")
case plainNumRE.MatchString(numOID):
t.oid = strings.TrimPrefix(numOID, ".")
default:
return fmt.Errorf("num_oid %q has an unsupported template", numOID)
}
return nil
}
// symbolicTarget derives the polled OID from the MIB object alone.
func symbolicTarget(t *target, rf *ref, valueSym string) error {
switch {
case rf.kind == mib.KindScalar && rf.suffix == "":
t.oid = rf.oid + ".0"
case rf.kind == mib.KindScalar, !rf.known:
t.oid = rf.oid
case rf.kind == mib.KindColumn:
t.oid, t.indexed = rf.oid, true
default:
return fmt.Errorf("%s is a %s, not a scalar or table column", valueSym, rf.kind)
}
return nil
}
// labelFrom derives index_label from a descr template.
func (im *importer) labelFrom(descr string, src string, mt *metric) {
sym := ""
if wholeRefRE.MatchString(strings.TrimSpace(descr)) {
sym = strings.TrimSpace(descr)
} else if m := refRE.FindStringSubmatch(descr); m != nil {
sym = m[1]
}
if sym == "" {
mt.notes = append(mt.notes, "no label column: descr is "+strconv.Quote(oneLine(descr))+"; add index_label if this is a table column")
return
}
mod, name, _ := strings.Cut(sym, "::")
if mod == "IF-MIB" && (name == "ifDescr" || name == "ifName" || name == "ifAlias" || name == "ifIndex") {
mt.label = &label{"ifIndex", "iface"}
return
}
if s, ok := profile.LookupSymbol(name); ok {
mt.label = &label{s.Name, "component"}
return
}
r, err := im.resolve(sym)
if err != nil || r.kind != mib.KindColumn {
mt.notes = append(mt.notes, fmt.Sprintf("label column %s could not be resolved (%v); set index_label", sym, labelErr(err)))
return
}
mt.label = &label{r.name, "component"}
if prev, dup := im.byOID[r.oid]; dup {
// another metric (possibly mt itself) already polls this column; use its name as the label
mt.label.column = prev.name
return
}
h := &metric{name: r.name, oid: r.oid, unit: "text", helper: true, src: src,
notes: []string{"added as the index_label column of the metrics that reference it"}}
for im.names[h.name] || im.gnames[h.name] {
h.name += "_label"
}
mt.label.column = h.name
im.names[h.name] = true
im.byOID[h.oid] = h
im.metrics = append(im.metrics, h)
}
func labelErr(err error) string {
if err == nil {
return "not a table column"
}
return err.Error()
}
// benign LibreNMS fields that only affect its UI or limits calculations; they are
// not reported per metric.
var benignKeys = map[string]bool{
"index": true, "group": true, "category": true, "entPhysicalIndex": true, "entPhysicalIndex_measured": true,
"snmp_flags": true, "snmp_bulk": true, "snmp_no_Ih_flag": true, "skip_limits_calc": true, "type": true,
"class": true, "state_name": true, "poller_type": true,
}
// entry describes one data[] element to convert.
type entry struct {
src string
e map[string]any
valueSym string
numOID string
unit string
scale float64 // preset scale (precision, units); divisor/multiplier are applied on top
states []stateVal
handled map[string]bool
defaults map[string]any // sensor-class options
extra []string // notes to attach
}
func (im *importer) addEntry(en entry) {
t, err := im.resolveTarget(en.valueSym, en.numOID)
if err != nil {
im.skip(en.src, "%v", err)
return
}
if g, ok := im.generic[t.oid]; ok {
im.skip(en.src, "OID %s is already collected by the generic profile (%s)", t.oid, g)
return
}
if prev, dup := im.byOID[t.oid]; dup {
im.skip(en.src, "OID %s duplicates %s (%s)", t.oid, prev.name, prev.src)
return
}
mt := &metric{oid: t.oid, unit: en.unit, src: en.src, states: en.states, notes: append([]string(nil), en.extra...)}
mt.name = im.uniqueName(t)
im.names[mt.name] = true // reserved before labelFrom, which may add helper metrics
im.byOID[mt.oid] = mt
if !t.known && en.valueSym != "" && !numericOIDRE.MatchString(en.valueSym) {
mt.notes = append(mt.notes, fmt.Sprintf("symbol %s could not be resolved from the MIBs; OID comes from num_oid", en.valueSym))
}
if t.indexed {
if d := str(en.e["descr"]); d != "" {
im.labelFrom(d, en.src, mt)
} else {
mt.notes = append(mt.notes, "table column with no descr: add index_label")
}
}
applyScale(mt, en)
applySkipValues(mt, en)
noteUnmapped(mt, en)
im.metrics = append(im.metrics, mt)
}
// uniqueName picks a metric name from the MIB symbol (or the OID tail) that
// collides with neither the generic profile nor an earlier import.
func (im *importer) uniqueName(t target) string {
name := t.name
if name == "" {
arcs := strings.Split(t.oid, ".")
name = "oid_" + strings.Join(arcs[max(0, len(arcs)-4):], "_")
}
name = badNameRE.ReplaceAllString(name, "_")
if im.gnames[name] || im.names[name] {
for i := 2; ; i++ {
n := fmt.Sprintf("%s_%d", name, i)
if !im.names[n] {
return n
}
}
}
return name
}
// applyScale turns divisor / multiplier into a scale on top of any preset.
func applyScale(mt *metric, en entry) {
scale := en.scale
div, mul := en.e["divisor"], en.e["multiplier"]
if div == nil && en.defaults != nil {
div = en.defaults["divisor"]
}
apply := func(key string, v any, f func(float64)) {
if v == nil {
return
}
n, ok := number(v)
if !ok || n <= 0 {
mt.notes = append(mt.notes,
fmt.Sprintf("%s %s not mapped to scale (not a positive number)", key, strconv.Quote(oneLine(fmt.Sprint(v)))))
return
}
f(n)
}
if scale == 0 {
scale = 1
}
apply("divisor", div, func(n float64) { scale /= n })
apply("multiplier", mul, func(n float64) { scale *= n })
if scale != 1 {
mt.scale = scale
}
}
// applySkipValues maps skip_values to not_available and notes the LibreNMS
// transforms a profile cannot express.
func applySkipValues(mt *metric, en entry) {
sv := en.e["skip_values"]
if sv == nil && en.defaults != nil {
sv = en.defaults["skip_values"]
}
if sv != nil {
self := []string{en.valueSym}
if oid, ok := en.e["oid"].(string); ok {
self = append(self, oid)
}
na, ns := parseSkip(sv, self)
mt.notAvail = na
mt.notes = append(mt.notes, ns...)
}
for _, k := range []string{"skip_value_lt", "skip_value_gt"} {
if v, ok := en.e[k]; ok {
mt.notes = append(mt.notes, fmt.Sprintf("%s: %v is not mapped (profiles only drop exact not_available values)", k, v))
}
}
if u := str(en.e["user_func"]); u != "" {
mt.notes = append(mt.notes, "user_func "+u+" transforms the value in LibreNMS; not mapped")
}
if strings.EqualFold(str(en.e["rrd_type"]), "counter") {
mt.notes = append(mt.notes, "LibreNMS stores this as a COUNTER; probably a counter32/counter64 once verified")
}
}
// noteUnmapped lists the LibreNMS fields the importer neither used nor knows to be benign.
func noteUnmapped(mt *metric, en entry) {
var unmapped []string
for _, k := range sortedKeys(en.e) {
if !en.handled[k] && !benignKeys[k] {
unmapped = append(unmapped, k)
}
}
if len(unmapped) > 0 {
mt.notes = append(mt.notes, "LibreNMS fields not mapped: "+strings.Join(unmapped, ", "))
}
}
// parseSkip maps skip_values. Only exact numeric equality on the metric's own
// object becomes not_available; everything else is returned as notes.
func parseSkip(v any, self []string) (na []float64, notes []string) {
items := asList(v)
if items == nil {
items = []any{v}
}
for _, it := range items {
if f, ok := number(it); ok {
if _, isStr := it.(string); !isStr {
na = appendFloat(na, f)
continue
}
}
m := asMap(it)
if m == nil {
notes = append(notes, fmt.Sprintf("skip_values entry %q not mapped", oneLine(fmt.Sprint(it))))
continue
}
if f, ok := exactSkip(m, self); ok {
na = appendFloat(na, f)
continue
}
what := str(m["oid"])
if what == "" {
what = str(m["device"])
}
notes = append(notes, fmt.Sprintf("skip when %s %s %s is not mapped (not an exact value of this object)",
what, str(m["op"]), oneLine(fmt.Sprint(m["value"]))))
}
return na, notes
}
// exactSkip reports the value of a "this object equals N" condition.
func exactSkip(m map[string]any, self []string) (float64, bool) {
op := str(m["op"])
f, isNum := number(m["value"])
_, valIsBool := m["value"].(bool)
if (op != "=" && op != "==") || !isNum || valIsBool || m["oid"] == nil {
return 0, false
}
o := strings.TrimSpace(str(m["oid"]))
for _, s := range self {
if s != "" && (o == s || trimMod(o) == trimMod(s)) {
return f, true
}
}
return 0, false
}
func appendFloat(dst []float64, f float64) []float64 {
if slices.Contains(dst, f) {
return dst
}
return append(dst, f)
}
func trimMod(s string) string {
if _, n, ok := strings.Cut(s, "::"); ok {
return n
}
return s
}
func parseStates(v any) []stateVal {
var out []stateVal
for _, it := range asList(v) {
m := asMap(it)
if m == nil {
continue
}
n, ok := number(m["value"])
if !ok {
continue
}
out = append(out, stateVal{int(n), str(m["descr"])})
}
return out
}

273
internal/librenms/import.go Normal file
View File

@@ -0,0 +1,273 @@
// Package librenms converts LibreNMS OS definitions into draft nmon profiles.
// It reads LibreNMS's YAML definition files as data, maps their fields to nmon's
// profile schema and writes new YAML. It contains no LibreNMS code and is linked
// only into the separate nmon-librenms-import tool, never into nmon itself.
package librenms
import (
"errors"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"gopkg.in/yaml.v3"
"github.com/v10networks/nmon/internal/mib"
"github.com/v10networks/nmon/internal/profile"
)
// Options configures an Importer.
type Options struct {
// LibreNMS is the root of a LibreNMS checkout.
LibreNMS string
// MIBDirs are searched for MIBs before the checkout's own mibs/ tree.
MIBDirs []string
}
// Draft is the result of importing one OS.
type Draft struct {
OS string
Name string // profile name and file base name
YAML []byte
Mapped int // metrics written, excluding label helpers
Helpers int // label-column metrics added so index_label resolves
Skipped []Skip
Notes []string // match conditions that could not be carried over
}
// Importer converts OS definitions from one checkout.
type Importer struct {
opts Options
shipped map[string]bool
generic map[string]string
gnames map[string]bool
commit string
}
// New prepares an Importer. It loads the shipped profiles so that drafts neither
// repeat what the generic profile already collects nor take a shipped profile's name.
func New(opts Options) (*Importer, error) {
if st, err := os.Stat(filepath.Join(opts.LibreNMS, "resources", "definitions")); err != nil || !st.IsDir() {
if st2, err2 := os.Stat(filepath.Join(opts.LibreNMS, "includes", "definitions")); err2 != nil || !st2.IsDir() {
return nil, fmt.Errorf("%s does not look like a LibreNMS checkout (no resources/definitions or includes/definitions)",
opts.LibreNMS)
}
}
set, err := profile.Load("")
if err != nil {
return nil, err
}
im := &Importer{opts: opts, shipped: map[string]bool{}, generic: map[string]string{}, gnames: map[string]bool{},
commit: gitCommit(opts.LibreNMS)}
for _, n := range set.Names() {
im.shipped[n] = true
}
if g, ok := set.Lookup("generic"); ok {
for _, m := range g.Metrics {
im.generic[m.OID] = m.Name
im.gnames[m.Name] = true
}
}
return im, nil
}
func (im *Importer) detectionPaths(osName string) []string {
r := im.opts.LibreNMS
return []string{
filepath.Join(r, "resources", "definitions", "os_detection", osName+".yaml"),
filepath.Join(r, "includes", "definitions", osName+".yaml"),
}
}
func (im *Importer) discoveryPaths(osName string) []string {
r := im.opts.LibreNMS
return []string{
filepath.Join(r, "resources", "definitions", "os_discovery", osName+".yaml"),
filepath.Join(r, "includes", "definitions", "discovery", osName+".yaml"),
}
}
// OSList returns every OS with a detection or discovery definition, sorted.
func (im *Importer) OSList() ([]string, error) {
seen := map[string]bool{}
r := im.opts.LibreNMS
for _, d := range []string{
filepath.Join(r, "resources", "definitions", "os_detection"),
filepath.Join(r, "resources", "definitions", "os_discovery"),
filepath.Join(r, "includes", "definitions", "discovery"),
} {
ents, err := os.ReadDir(d)
if err != nil {
continue
}
for _, e := range ents {
if n, ok := strings.CutSuffix(e.Name(), ".yaml"); ok && !e.IsDir() {
seen[n] = true
}
}
}
if ents, err := os.ReadDir(filepath.Join(r, "includes", "definitions")); err == nil {
for _, e := range ents {
if n, ok := strings.CutSuffix(e.Name(), ".yaml"); ok && !e.IsDir() {
seen[n] = true
}
}
}
if len(seen) == 0 {
return nil, fmt.Errorf("no OS definitions found under %s", r)
}
out := make([]string, 0, len(seen))
for n := range seen {
out = append(out, n)
}
sort.Strings(out)
return out, nil
}
func readFirst(paths []string) (map[string]any, string, error) {
for _, p := range paths {
b, err := os.ReadFile(p)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return nil, p, err
}
var doc any
if err := yaml.Unmarshal(b, &doc); err != nil {
return nil, p, fmt.Errorf("%s: %w", p, err)
}
m := asMap(doc)
if m == nil {
return nil, p, fmt.Errorf("%s: not a mapping", p)
}
return m, p, nil
}
return nil, "", nil
}
// Import converts one OS into a draft profile.
func (im *Importer) Import(osName string) (*Draft, error) {
if osName == "" || strings.ContainsAny(osName, "/\\") {
return nil, fmt.Errorf("bad OS name %q", osName)
}
det, _, err := readFirst(im.detectionPaths(osName))
if err != nil {
return nil, err
}
disc, _, err := readFirst(im.discoveryPaths(osName))
if err != nil {
return nil, err
}
if det == nil && disc == nil {
return nil, fmt.Errorf("no LibreNMS definition for OS %q", osName)
}
conv := im.convert(det, disc)
var m match
if det != nil {
m = parseDetection(det)
}
d := &Draft{OS: osName, Name: osName, Skipped: conv.skips, Notes: m.notes}
if im.shipped[osName] {
d.Name = osName + "-librenms" // never replace a shipped profile by accident
}
for _, mt := range conv.metrics {
if mt.helper {
d.Helpers++
} else {
d.Mapped++
}
}
d.YAML = im.render(d, m, conv.metrics)
return d, nil
}
// mibPath is the MIB search path: the operator's dirs, the OS's vendor dir, then the whole tree.
func (im *Importer) mibPath(det map[string]any) []string {
path := append([]string(nil), im.opts.MIBDirs...)
tree := filepath.Join(im.opts.LibreNMS, "mibs")
if det != nil {
for _, d := range strs(det["mib_dir"]) {
if p := filepath.Join(tree, filepath.FromSlash(d)); isDir(p) {
path = append(path, p)
}
}
}
if isDir(tree) {
path = append(path, tree)
}
return path
}
// convert runs the discovery definition through a fresh single-OS importer.
func (im *Importer) convert(det, disc map[string]any) *importer {
conv := &importer{l: mib.NewLoader(im.mibPath(det)...), generic: im.generic, gnames: im.gnames,
byOID: map[string]*metric{}, names: map[string]bool{}}
conv.l.DropDescriptions()
if disc != nil {
mods := asMap(disc["modules"])
if mods == nil && disc["modules"] != nil {
conv.skip("modules", "not a mapping")
}
conv.convertModules(mods)
}
return conv
}
func isDir(p string) bool {
st, err := os.Stat(p)
return err == nil && st.IsDir()
}
func (im *Importer) provenance(osName string) string {
if im.commit != "" {
return fmt.Sprintf("imported from LibreNMS %s definitions (%s); review before use", osName, im.commit)
}
return fmt.Sprintf("imported from LibreNMS %s definitions; review before use", osName)
}
// gitCommit reads the short HEAD commit of a checkout without running git.
func gitCommit(dir string) string {
head, err := os.ReadFile(filepath.Join(dir, ".git", "HEAD"))
if err != nil {
return ""
}
h := strings.TrimSpace(string(head))
if ref, ok := strings.CutPrefix(h, "ref: "); ok {
if h = resolveRef(dir, ref); h == "" {
return ""
}
}
if len(h) < 8 {
return ""
}
for _, c := range h {
if !strings.ContainsRune("0123456789abcdef", c) {
return ""
}
}
return h[:8]
}
// resolveRef finds a ref's commit in the loose ref file, then in packed-refs.
func resolveRef(dir, ref string) string {
if b, err := os.ReadFile(filepath.Join(dir, ".git", filepath.FromSlash(ref))); err == nil {
return strings.TrimSpace(string(b))
}
pr, err := os.ReadFile(filepath.Join(dir, ".git", "packed-refs"))
if err != nil {
return ""
}
h := ""
for _, line := range strings.Split(string(pr), "\n") {
if f := strings.Fields(line); len(f) == 2 && f[1] == ref {
h = f[0]
}
}
return h
}

View File

@@ -0,0 +1,308 @@
package librenms
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/v10networks/nmon/internal/profile"
)
func importAcme(t *testing.T) (*Draft, *profile.Profile) {
t.Helper()
imp, err := New(Options{LibreNMS: filepath.Join("testdata", "lnms")})
if err != nil {
t.Fatal(err)
}
d, err := imp.Import("acme")
if err != nil {
t.Fatal(err)
}
p, err := profile.Parse(d.Name+".yaml", d.YAML)
if err != nil {
t.Fatalf("draft does not load: %v\n%s", err, d.YAML)
}
return d, p
}
func metricsByName(p *profile.Profile) map[string]profile.Metric {
m := map[string]profile.Metric{}
for _, x := range p.Metrics {
m[x.Name] = x
}
return m
}
func TestMatchMapping(t *testing.T) {
_, p := importAcme(t)
if got := strings.Join(p.Match.SysObjectID, ","); got != "1.3.6.1.4.1.99999.1,1.3.6.1.4.1.99999.2" {
t.Errorf("sysObjectID = %s", got)
}
want := []string{`ACME \(router\)`, `(?i)^Acme OS (?<ver>\d+)`}
if strings.Join(p.Match.SysDescr, "|") != strings.Join(want, "|") {
t.Errorf("sysDescr = %q, want %q", p.Match.SysDescr, want)
}
if !strings.Contains(p.Provenance, "imported from LibreNMS acme definitions") || !strings.HasSuffix(p.Provenance, "review before use") {
t.Errorf("provenance = %q", p.Provenance)
}
}
func TestMetricMapping(t *testing.T) {
d, p := importAcme(t)
m := metricsByName(p)
for _, x := range p.Metrics {
if x.Type != profile.Unverified || x.Imported != "librenms" {
t.Errorf("%s: type %s imported %q", x.Name, x.Type, x.Imported)
}
}
cpu := m["acmeCpuLoad"]
if cpu.OID != "1.3.6.1.4.1.99999.3.1.3" || cpu.Unit != "percent" || cpu.Scale != 0.1 ||
cpu.IndexLabel == nil || cpu.IndexLabel.Column != "acmeCpuName" || cpu.IndexLabel.As != "component" {
t.Errorf("acmeCpuLoad = %+v", cpu)
}
if label := m["acmeCpuName"]; label.OID != "1.3.6.1.4.1.99999.3.1.2" || label.Unit != "text" {
t.Errorf("label helper = %+v", label)
}
if _, dup := m["hrProcessorLoad"]; dup {
t.Error("hrProcessorLoad is collected by the generic profile and must not be redefined")
}
// mempool: bytes scale from precision; percent_used has none; unmappable skip_values become comments
if u := m["acmeMemUsed"]; u.Unit != "bytes" || u.Scale != 1024 {
t.Errorf("acmeMemUsed = %+v", u)
}
if f := m["acmeFan"]; f.Unit != "percent" || f.Scale != 0 {
t.Errorf("acmeFan (percent_used) = %+v", f)
}
// sensors
temp := m["acmeTemp"]
if temp.OID != "1.3.6.1.4.1.99999.1.0" || temp.Unit != "celsius" || temp.Scale != 0.1 || temp.IndexLabel != nil ||
len(temp.NotAvailable) != 2 || temp.NotAvailable[0] != 0 || temp.NotAvailable[1] != -1 {
t.Errorf("acmeTemp = %+v", temp)
}
volt := m["acmeVolt"]
if volt.Unit != "volts" || volt.Scale != 0.002 || len(volt.NotAvailable) != 1 || volt.NotAvailable[0] != -2147483648 ||
volt.IndexLabel == nil || volt.IndexLabel.Column != "ifIndex" || volt.IndexLabel.As != "iface" {
t.Errorf("acmeVolt = %+v", volt)
}
if c := m["oid_1_99999_50_0"]; c.OID != "1.3.6.1.4.1.99999.50.0" || c.Unit != "count" || c.IndexLabel != nil {
t.Errorf("noindex count = %+v (all: %v)", c, names(p))
}
if s := m["acmeState"]; s.Unit != "enum" || s.Type != profile.Unverified || s.Enum[1] != "ok" || s.Enum[2] != "failed" {
t.Errorf("acmeState = %+v", s)
}
if a := m["acmeAir"]; a.OID != "1.3.6.1.4.1.99999.3.1.9" || a.Unit != "count" || a.Scale != 0 {
t.Errorf("acmeAir = %+v", a)
}
y := string(d.YAML)
for _, want := range []string{
"skip when ACME-MIB::acmeMemTotal = 0 is not mapped", // a condition on a different object
"user_func fahrenheit_to_celsius",
"LibreNMS fields not mapped: high_limit",
"divisor \"1.0E+{{ ACME-MIB::acmeTemp }}\" not mapped to scale",
`LibreNMS class "airflow" has no unit`,
"skip_value_lt: 0 is not mapped",
"scale from units 512",
"discovery[2] has an extra condition \"sysDescr_except\"",
"sysDescr_regex \"/(?=lookahead)x/\" skipped",
} {
if !strings.Contains(y, want) {
t.Errorf("draft lacks %q", want)
}
}
skipped := map[string]string{}
for _, s := range d.Skipped {
skipped[s.Where] = s.Reason
}
for where, frag := range map[string]string{
"sensors.temperature[1]": "duplicates",
"storage[0].used": "constant",
"sensors.humidity[0]": "not found",
"sensors.additional_oids": "helper",
"wireless": "not mapped",
"os": "inventory",
"storage[0].size": "",
} {
r, ok := skipped[where]
if where == "storage[0].size" {
if ok {
t.Errorf("storage size should have mapped through size_num_oid, skipped: %s", r)
}
continue
}
if !ok || !strings.Contains(r, frag) {
t.Errorf("skip %s = %q, want mention of %q", where, r, frag)
}
}
if d.Mapped == 0 || d.Helpers == 0 {
t.Errorf("counts mapped=%d helpers=%d", d.Mapped, d.Helpers)
}
}
func names(p *profile.Profile) []string {
var out []string
for _, m := range p.Metrics {
out = append(out, m.Name)
}
return out
}
func TestDraftsLoadWithShippedProfiles(t *testing.T) {
imp, err := New(Options{LibreNMS: filepath.Join("testdata", "lnms")})
if err != nil {
t.Fatal(err)
}
d, err := imp.Import("acme")
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, d.Name+".yaml"), d.YAML, 0o644); err != nil {
t.Fatal(err)
}
set, err := profile.Load(dir)
if err != nil {
t.Fatalf("shipped + draft: %v", err)
}
r, ok := set.Lookup("acme")
if !ok {
t.Fatal("acme missing")
}
if len(r.PollableMetrics()) == 0 {
t.Log("(unverified metrics are never polled, as intended)")
}
for _, g := range r.PollableMetrics() {
for _, m := range g.Metrics {
if m.Imported != "" {
t.Errorf("unverified metric %s is pollable", m.Name)
}
}
}
}
func TestShippedNameCollisionGetsSuffix(t *testing.T) {
root := t.TempDir()
dd := filepath.Join(root, "resources", "definitions", "os_detection")
if err := os.MkdirAll(dd, 0o755); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(dd, "fortigate.yaml"), "os: fortigate\ndiscovery:\n - sysObjectID: .1.3.6.1.4.1.12356.101.1\n")
imp, err := New(Options{LibreNMS: root})
if err != nil {
t.Fatal(err)
}
d, err := imp.Import("fortigate")
if err != nil {
t.Fatal(err)
}
if d.Name != "fortigate-librenms" {
t.Errorf("name = %s", d.Name)
}
if _, err := profile.Parse(d.Name+".yaml", d.YAML); err != nil {
t.Error(err)
}
if _, err := imp.Import("nonexistent"); err == nil {
t.Error("expected an error for an unknown OS")
}
}
func TestGitCommit(t *testing.T) {
dir := t.TempDir()
if err := os.MkdirAll(filepath.Join(dir, ".git", "refs", "heads"), 0o755); err != nil {
t.Fatal(err)
}
mustWrite(t, filepath.Join(dir, ".git", "HEAD"), "ref: refs/heads/main\n")
if got := gitCommit(dir); got != "" {
t.Errorf("no ref file: %q", got)
}
mustWrite(t, filepath.Join(dir, ".git", "refs", "heads", "main"), "06080bb3aabbccddeeff00112233445566778899\n")
if got := gitCommit(dir); got != "06080bb3" {
t.Errorf("commit = %q", got)
}
}
// TestLibreNMSCheckout imports the OS set from the task against a real checkout
// (NMON_LIBRENMS_DIR) and checks every draft loads next to the shipped profiles.
func TestLibreNMSCheckout(t *testing.T) {
root := os.Getenv("NMON_LIBRENMS_DIR")
if root == "" {
t.Skip("NMON_LIBRENMS_DIR not set")
}
imp, err := New(Options{LibreNMS: root})
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
for _, os := range []string{"junos", "ios", "nxos", "arista_eos", routerOS, "edgeos", "fortigate", "pfsense", "linux", "windows", "apc"} {
d, err := imp.Import(os)
if err != nil {
t.Errorf("%s: %v", os, err)
continue
}
t.Logf("%-11s -> %-20s mapped %3d, label columns %2d, skipped %2d", os, d.Name, d.Mapped, d.Helpers, len(d.Skipped))
if err := writeFile(dir, d); err != nil {
t.Fatal(err)
}
}
set, err := profile.Load(dir)
if err != nil {
t.Fatalf("drafts do not load with the shipped profiles: %v", err)
}
if _, ok := set.Lookup("junos"); !ok {
t.Error("junos draft missing from the set")
}
}
// TestLibreNMSAllOS imports every OS definition in the checkout and requires each
// draft to load, so the mapping is exercised over the whole corpus.
func TestLibreNMSAllOS(t *testing.T) {
root := os.Getenv("NMON_LIBRENMS_DIR")
if root == "" || testing.Short() {
t.Skip("NMON_LIBRENMS_DIR not set")
}
imp, err := New(Options{LibreNMS: root})
if err != nil {
t.Fatal(err)
}
list, err := imp.OSList()
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
mapped, skipped, failed := 0, 0, 0
for _, name := range list {
d, err := imp.Import(name)
if err != nil {
failed++
t.Logf("%s: %v", name, err)
continue
}
mapped += d.Mapped + d.Helpers
skipped += len(d.Skipped)
if err := writeFile(dir, d); err != nil {
t.Fatal(err)
}
}
t.Logf("%d OS definitions: %d metrics, %d skipped entries, %d unreadable", len(list), mapped, skipped, failed)
if _, err := profile.Load(dir); err != nil {
t.Fatalf("drafts do not load: %v", err)
}
}
func writeFile(dir string, d *Draft) error {
return os.WriteFile(filepath.Join(dir, d.Name+".yaml"), d.YAML, 0o644)
}
// misspell mistakes the real LibreNMS os id (RouterOS, lowercase) for a typo of "routers".
const routerOS = "router" + "os"
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}

View File

@@ -0,0 +1,172 @@
package librenms
import (
"fmt"
"strconv"
)
// convertModules walks the modules: block of an os_discovery file.
func (im *importer) convertModules(mods map[string]any) {
for _, k := range sortedKeys(mods) {
sec := asMap(mods[k])
switch k {
case "processors":
for i, it := range asList(sec["data"]) {
im.processor(fmt.Sprintf("processors[%d]", i), asMap(it))
}
case "mempools":
for i, it := range asList(sec["data"]) {
im.pool(fmt.Sprintf("mempools[%d]", i), asMap(it), "mempool")
}
case "storage":
for i, it := range asList(sec["data"]) {
im.pool(fmt.Sprintf("storage[%d]", i), asMap(it), "storage")
}
case "sensors":
im.sensors(sec)
case "os":
im.skip("os", "version/hardware/serial extraction is device inventory text, not a metric")
default:
im.skip(k, "module not mapped")
}
}
}
func (im *importer) processor(src string, e map[string]any) {
if e == nil {
im.skip(src, "not a mapping")
return
}
sym := str(e["value"])
if sym == "" {
sym = str(e["oid"])
}
en := entry{src: src, e: e, valueSym: sym, numOID: str(e["num_oid"]), unit: "percent",
handled: handledKeys("oid", "value", "num_oid", "descr", "divisor", "multiplier", "skip_values", "precision")}
if p, ok := number(e["precision"]); ok && p != 0 && p != 1 {
// LibreNMS divides the raw reading by a positive precision and multiplies by a negative one
if p > 0 {
en.scale = 1 / p
} else {
en.scale = -p
}
en.extra = append(en.extra, fmt.Sprintf("scale from precision %s (LibreNMS divides by a positive precision); verify", fmtNum(p)))
}
im.addEntry(en)
}
// poolScale reads the unit conversion of a mempool (precision) or storage (units) block.
func poolScale(e map[string]any, kind string) (scale float64, extra []string) {
if kind == "mempool" {
if p, ok := number(e["precision"]); ok && p > 0 && p != 1 {
return p, []string{"scale from precision " + fmtNum(p) + " (raw units to bytes)"}
}
return 0, nil
}
u := e["units"]
if u == nil {
return 0, nil
}
n, ok := number(u)
switch {
case !ok || n <= 0:
return 0, []string{"units " + strconv.Quote(oneLine(fmt.Sprint(u))) +
" is read from the device in LibreNMS (allocation units); apply it as scale"}
case n != 1:
return n, []string{"scale from units " + fmtNum(n) + " (allocation unit to bytes)"}
}
return 0, nil
}
// poolHandled lists the keys pool consumes, so they are not reported as unmapped.
var poolHandled = []string{"oid", "descr", "skip_values", "precision", "units", "divisor", "multiplier"}
// pool converts mempools and storage blocks, which name several value objects.
func (im *importer) pool(src string, e map[string]any, kind string) {
if e == nil {
im.skip(src, "not a mapping")
return
}
handled := map[string]bool{}
for _, k := range poolHandled {
handled[k] = true
}
scale, extra := poolScale(e, kind)
found := false
for _, f := range []string{"percent_used", "used", "free", "total", "size"} {
v, has := e[f]
numKey := f + "_num_oid"
handled[f], handled[numKey] = true, true
if !has && e[numKey] == nil {
continue
}
unit, sc, ex := "bytes", scale, extra
if f == "percent_used" {
unit, sc, ex = "percent", 0, nil
}
sym := ""
if s, ok := v.(string); ok {
sym = s
} else if has {
// a constant (integer) total, not an SNMP object
im.skip(src+"."+f, "constant value %v, not an SNMP object", v)
continue
}
found = true
im.addEntry(entry{src: src + "." + f, e: e, valueSym: sym, numOID: str(e[numKey]), unit: unit, scale: sc,
handled: handled, extra: ex})
}
if !found {
im.skip(src, "no value object found")
}
}
var sensorHandled = handledKeys("oid", "value", "num_oid", "descr", "divisor", "multiplier", "skip_values", "states",
"skip_value_lt", "skip_value_gt", "user_func", "rrd_type")
func handledKeys(keys ...string) map[string]bool {
m := make(map[string]bool, len(keys))
for _, k := range keys {
m[k] = true
}
return m
}
func (im *importer) sensors(sec map[string]any) {
for _, class := range sortedKeys(sec) {
cs := asMap(sec[class])
if class == "additional_oids" {
im.skip("sensors.additional_oids", "helper OIDs read alongside sensors, not metrics")
continue
}
if cs == nil {
im.skip("sensors."+class, "not a mapping")
continue
}
unit, ok := sensorUnits[class]
var cnote []string
if !ok {
unit = "count"
cnote = []string{fmt.Sprintf("LibreNMS class %q has no unit in the profile vocabulary; count used, review", class)}
}
defaults := asMap(cs["options"])
for i, it := range asList(cs["data"]) {
src := fmt.Sprintf("sensors.%s[%d]", class, i)
e := asMap(it)
if e == nil {
im.skip(src, "not a mapping")
continue
}
sym := str(e["value"])
if sym == "" {
sym = str(e["oid"])
}
en := entry{src: src, e: e, valueSym: sym, numOID: str(e["num_oid"]), unit: unit, defaults: defaults,
extra: append([]string(nil), cnote...), handled: sensorHandled}
if class == "state" {
en.states = parseStates(e["states"])
}
im.addEntry(en)
}
}
}

100
internal/librenms/render.go Normal file
View File

@@ -0,0 +1,100 @@
package librenms
import (
"bytes"
"fmt"
"strconv"
"strings"
)
// render writes the draft profile YAML: header comments, match, then one block per metric.
func (im *Importer) render(d *Draft, m match, metrics []*metric) []byte {
var b bytes.Buffer
w := func(format string, a ...any) { fmt.Fprintf(&b, format, a...) }
im.renderHeader(w, d)
renderMatch(w, m)
if len(d.Skipped) > 0 {
w("# Skipped LibreNMS entries:\n")
for _, s := range d.Skipped {
w("# %s: %s\n", s.Where, oneLine(s.Reason))
}
}
if len(metrics) == 0 {
w("metrics: []\n")
return b.Bytes()
}
w("metrics:\n")
for _, mt := range metrics {
renderMetric(w, mt)
}
return b.Bytes()
}
type writeFunc func(format string, a ...any)
func (im *Importer) renderHeader(w writeFunc, d *Draft) {
w("# DRAFT imported by nmon-librenms-import from the LibreNMS %q definitions. Review before use.\n", d.OS)
w("# Every metric is type: unverified, so the poller never collects it. `nmon profile test` confirms the SNMP type\n")
w("# against a device or fixture; then set the real type (and enum for status) and drop imported: librenms.\n")
w("# interval is a placeholder (5m). Names, OIDs and units come from LibreNMS's published OS definitions; no LibreNMS code is used.\n")
if d.Name != d.OS {
w("# Named %s because a shipped profile is already called %s.\n", d.Name, d.OS)
}
w("# %d metrics mapped (+%d label columns), %d definition entries skipped.\n", d.Mapped, d.Helpers, len(d.Skipped))
w("name: %s\nextends: generic\nprovenance: %s\n", d.Name, strconv.Quote(im.provenance(d.OS)))
}
func renderMatch(w writeFunc, m match) {
if len(m.sysObjectID)+len(m.sysDescr) > 0 {
w("match:\n")
renderList(w, "sysObjectID", m.sysObjectID)
renderList(w, "sysDescr", m.sysDescr)
} else {
w("# no usable match: section could be derived; add one before use.\n")
}
for _, n := range m.notes {
w("# match: %s\n", oneLine(n))
}
}
func renderList(w writeFunc, key string, items []string) {
if len(items) == 0 {
return
}
w(" %s:\n", key)
for _, o := range items {
w(" - %s\n", strconv.Quote(o))
}
}
func renderMetric(w writeFunc, mt *metric) {
w(" # from %s\n", mt.src)
for _, n := range mt.notes {
w(" # %s\n", oneLine(n))
}
w(" - name: %s\n oid: %s\n type: unverified\n unit: %s\n interval: 5m\n", mt.name, mt.oid, mt.unit)
if mt.scale != 0 {
w(" scale: %s\n", fmtNum(mt.scale))
}
if mt.label != nil {
w(" index_label: { column: %s, as: %s }\n", mt.label.column, mt.label.as)
}
if len(mt.notAvail) > 0 {
var xs []string
for _, f := range mt.notAvail {
xs = append(xs, fmtNum(f))
}
w(" not_available: [%s]\n", strings.Join(xs, ", "))
}
if len(mt.states) > 0 {
w(" enum:\n")
seen := map[int]bool{}
for _, s := range mt.states {
if !seen[s.value] {
seen[s.value] = true
w(" %d: %s\n", s.value, strconv.Quote(s.descr))
}
}
}
w(" imported: librenms\n")
}

View File

@@ -0,0 +1,23 @@
-- hand-written for the importer tests
ACME-MIB DEFINITIONS ::= BEGIN
IMPORTS enterprises FROM SNMPv2-SMI DisplayString FROM SNMPv2-TC;
acme OBJECT IDENTIFIER ::= { enterprises 99999 }
acmeTemp OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current DESCRIPTION "t" ::= { acme 1 }
acmeHostName OBJECT-TYPE SYNTAX DisplayString MAX-ACCESS read-only STATUS current DESCRIPTION "n" ::= { acme 2 }
acmeCpuTable OBJECT-TYPE SYNTAX SEQUENCE OF AcmeCpuEntry MAX-ACCESS not-accessible STATUS current DESCRIPTION "t" ::= { acme 3 }
acmeCpuEntry OBJECT-TYPE SYNTAX AcmeCpuEntry MAX-ACCESS not-accessible STATUS current DESCRIPTION "e"
INDEX { acmeCpuIndex } ::= { acmeCpuTable 1 }
AcmeCpuEntry ::= SEQUENCE { acmeCpuIndex INTEGER, acmeCpuName DisplayString, acmeCpuLoad INTEGER, acmeMemUsed Gauge32, acmeMemTotal Gauge32, acmeFan INTEGER, acmeVolt INTEGER, acmeState INTEGER, acmeAir INTEGER }
acmeCpuIndex OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS not-accessible STATUS current DESCRIPTION "i" ::= { acmeCpuEntry 1 }
acmeCpuName OBJECT-TYPE SYNTAX DisplayString MAX-ACCESS read-only STATUS current DESCRIPTION "n" ::= { acmeCpuEntry 2 }
acmeCpuLoad OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current DESCRIPTION "l" ::= { acmeCpuEntry 3 }
acmeMemUsed OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current DESCRIPTION "u" ::= { acmeCpuEntry 4 }
acmeMemTotal OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current DESCRIPTION "t" ::= { acmeCpuEntry 5 }
acmeFan OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current DESCRIPTION "f" ::= { acmeCpuEntry 6 }
acmeVolt OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current DESCRIPTION "v" ::= { acmeCpuEntry 7 }
acmeState OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current DESCRIPTION "s" ::= { acmeCpuEntry 8 }
acmeAir OBJECT-TYPE SYNTAX INTEGER MAX-ACCESS read-only STATUS current DESCRIPTION "a" ::= { acmeCpuEntry 9 }
END

View File

@@ -0,0 +1,15 @@
os: acme
text: 'Acme OS'
mib_dir: acme
discovery:
-
sysObjectID:
- .1.3.6.1.4.1.99999.1
- .1.3.6.1.4.1.99999.2 # trailing comment
-
sysDescr: 'ACME (router)'
-
sysDescr_regex: '/^Acme OS (?<ver>\d+)/i'
sysDescr_except: 'virtual'
-
sysDescr_regex: '/(?=lookahead)x/'

View File

@@ -0,0 +1,106 @@
modules:
os:
hardware: ACME-MIB::acmeHostName.0
processors:
data:
-
oid: ACME-MIB::acmeCpuTable
value: ACME-MIB::acmeCpuLoad
num_oid: '.1.3.6.1.4.1.99999.3.1.3.{{ $index }}'
descr: ACME-MIB::acmeCpuName
precision: 10
-
oid: HOST-RESOURCES-MIB::hrProcessorLoad
num_oid: '.1.3.6.1.2.1.25.3.3.1.2.{{ $index }}'
descr: 'Processor'
mempools:
data:
-
oid: ACME-MIB::acmeCpuTable
used: ACME-MIB::acmeMemUsed
total: ACME-MIB::acmeMemTotal
percent_used: ACME-MIB::acmeFan
precision: 1024
descr: '{{ ACME-MIB::acmeCpuName }}'
skip_values:
-
oid: ACME-MIB::acmeMemTotal
op: '='
value: 0
-
oid: ACME-MIB::acmeCpuName
op: regex
value: '/fan/i'
storage:
data:
-
oid: SOMETHING-MIB::unknownTable
size: SOMETHING-MIB::unknownSize
size_num_oid: '.1.3.6.1.4.1.99999.9.1.{{ $index }}'
used: 4096
units: 512
descr: 'Disk {{ $index }}'
sensors:
additional_oids:
data:
-
oid: [ACME-MIB::acmeCpuName]
temperature:
options:
divisor: 10
data:
-
oid: ACME-MIB::acmeTemp
num_oid: '.1.3.6.1.4.1.99999.1.{{ $index }}'
descr: 'Board'
skip_values: [0, -1]
high_limit: 90
user_func: fahrenheit_to_celsius
-
oid: ACME-MIB::acmeCpuTable
value: ACME-MIB::acmeCpuLoad
num_oid: '.1.3.6.1.4.1.99999.3.1.3.{{ $index }}'
descr: 'dup of the processor above'
voltage:
data:
-
oid: ACME-MIB::acmeCpuTable
value: ACME-MIB::acmeVolt
num_oid: '.1.3.6.1.4.1.99999.3.1.7.{{ $index }}'
descr: '{{ IF-MIB::ifDescr }} supply'
divisor: 1000
multiplier: 2
skip_values: -2147483648
skip_value_lt: 0
airflow:
data:
-
oid: ACME-MIB::acmeCpuTable
value: ACME-MIB::acmeAir
num_oid: '.1.3.6.1.4.1.99999.3.1.9.{{ $index }}'
descr: 'Airflow {{ $index }}'
divisor: '1.0E+{{ ACME-MIB::acmeTemp }}'
count:
data:
-
oid: .1.3.6.1.4.1.99999.50.0
num_oid: '.1.3.6.1.4.1.99999.50.0{{ $noindex }}'
descr: 'Constant OID'
state:
data:
-
oid: ACME-MIB::acmeCpuTable
value: ACME-MIB::acmeState
num_oid: '.1.3.6.1.4.1.99999.3.1.8.{{ $index }}'
descr: 'State {{ ACME-MIB::acmeCpuName }}'
state_name: acmeState
states:
- { value: 1, generic: 0, descr: 'ok' }
- { value: 2, generic: 2, descr: 'failed' }
humidity:
data:
-
oid: NO-SUCH-MIB::noSuchThing
descr: 'Unresolvable'
wireless:
data: []

104
internal/librenms/yamlx.go Normal file
View File

@@ -0,0 +1,104 @@
package librenms
import (
"fmt"
"sort"
"strconv"
"strings"
)
// Helpers over decoded YAML (map[string]any, []any, scalars).
func asMap(v any) map[string]any {
switch m := v.(type) {
case map[string]any:
return m
case map[any]any:
out := make(map[string]any, len(m))
for k, x := range m {
out[fmt.Sprint(k)] = x
}
return out
}
return nil
}
func asList(v any) []any {
if l, ok := v.([]any); ok {
return l
}
return nil
}
// strs reads a scalar or a list of scalars as strings.
func strs(v any) []string {
switch x := v.(type) {
case nil:
return nil
case []any:
var out []string
for _, e := range x {
if s, ok := scalarString(e); ok {
out = append(out, s)
}
}
return out
}
if s, ok := scalarString(v); ok {
return []string{s}
}
return nil
}
func scalarString(v any) (string, bool) {
switch x := v.(type) {
case string:
return x, true
case int, int64, uint64, float64, bool:
return fmt.Sprint(x), true
}
return "", false
}
func str(v any) string {
s, _ := scalarString(v)
return s
}
// number reads a YAML number or a numeric string.
func number(v any) (float64, bool) {
switch x := v.(type) {
case int:
return float64(x), true
case int64:
return float64(x), true
case uint64:
return float64(x), true
case float64:
return x, true
case string:
f, err := strconv.ParseFloat(strings.TrimSpace(x), 64)
return f, err == nil
}
return 0, false
}
func sortedKeys(m map[string]any) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
// oneLine makes s safe inside a YAML comment.
func oneLine(s string) string {
s = strings.Join(strings.Fields(s), " ")
if len(s) > 160 {
s = s[:160] + "..."
}
return s
}
func fmtNum(f float64) string { return strconv.FormatFloat(f, 'f', -1, 64) }

167
internal/mib/lex.go Normal file
View File

@@ -0,0 +1,167 @@
package mib
import (
"fmt"
"strings"
)
type tokKind uint8
const (
tIdent tokKind = iota
tNumber
tString
tBString // 'hex'H or 'bin'B
tSym // single punctuation byte
tAssign // ::=
tRange // ..
)
type token struct {
kind tokKind
s string
line int32
}
func isLetter(c byte) bool { return c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c == '_' }
func isDigit(c byte) bool { return c >= '0' && c <= '9' }
// lexer scans MIB source one token at a time; each scan* method consumes one
// lexical class starting at l.i.
type lexer struct {
src []byte
i int
line int32
toks []token
}
// at reports whether src[i+off] == c.
func (l *lexer) at(off int, c byte) bool {
j := l.i + off
return j < len(l.src) && l.src[j] == c
}
func (l *lexer) emit(kind tokKind, s string) { l.toks = append(l.toks, token{kind, s, l.line}) }
// lex splits ASN.1 MIB text into tokens, dropping comments. A comment runs from
// "--" to the end of the line or the next "--".
func lex(src []byte) ([]token, error) {
l := &lexer{src: src, line: 1, toks: make([]token, 0, len(src)/6+16)}
for l.i < len(src) {
if err := l.step(src[l.i]); err != nil {
return nil, err
}
}
return l.toks, nil
}
// step consumes one token, whitespace run element or comment starting with c.
func (l *lexer) step(c byte) error {
switch {
case c == '\n':
l.line++
l.i++
case c == ' ' || c == '\t' || c == '\r' || c == '\f' || c == '\v':
l.i++
case c == '-' && l.at(1, '-'):
l.skipComment()
default:
return l.scanToken(c)
}
return nil
}
// scanToken consumes one token starting with c.
func (l *lexer) scanToken(c byte) error {
switch {
case c == '"':
return l.scanString()
case c == '\'':
l.scanQuoted()
case isDigit(c) || c == '-' && l.i+1 < len(l.src) && isDigit(l.src[l.i+1]):
l.scanNumber()
case isLetter(c):
l.scanIdent()
case c == ':' && l.at(1, ':') && l.at(2, '='):
l.emit(tAssign, "::=")
l.i += 3
case c == '.' && l.at(1, '.'):
l.emit(tRange, "..")
l.i += 2
default:
l.emit(tSym, string(c))
l.i++
}
return nil
}
func (l *lexer) skipComment() {
n := len(l.src)
l.i += 2
for l.i < n && l.src[l.i] != '\n' {
if l.at(0, '-') && l.at(1, '-') {
l.i += 2
return
}
l.i++
}
}
func (l *lexer) scanString() error {
start := l.i + 1
j := start
for j < len(l.src) && l.src[j] != '"' {
if l.src[j] == '\n' {
l.line++
}
j++
}
if j >= len(l.src) {
return fmt.Errorf("line %d: unterminated string", l.line)
}
l.emit(tString, string(l.src[start:j]))
l.i = j + 1
return nil
}
// scanQuoted reads a 'hex'H / 'bin'B literal, or a lone quote symbol.
func (l *lexer) scanQuoted() {
n := len(l.src)
j := l.i + 1
for j < n && l.src[j] != '\'' && l.src[j] != '\n' {
j++
}
if j >= n || l.src[j] != '\'' {
l.emit(tSym, "'")
l.i++
return
}
end := j + 1
if end < n && strings.IndexByte("HhBb", l.src[end]) >= 0 {
end++
}
l.emit(tBString, string(l.src[l.i:end]))
l.i = end
}
func (l *lexer) scanNumber() {
j := l.i + 1
for j < len(l.src) && isDigit(l.src[j]) {
j++
}
l.emit(tNumber, string(l.src[l.i:j]))
l.i = j
}
func (l *lexer) scanIdent() {
n := len(l.src)
j := l.i + 1
for j < n && (isLetter(l.src[j]) || isDigit(l.src[j]) || l.src[j] == '-') {
if l.src[j] == '-' && j+1 < n && l.src[j+1] == '-' {
break
}
j++
}
l.emit(tIdent, string(l.src[l.i:j]))
l.i = j
}

424
internal/mib/loader.go Normal file
View File

@@ -0,0 +1,424 @@
package mib
import (
"errors"
"fmt"
"os"
"sort"
"strconv"
"strings"
"sync"
)
// Loader finds MIB modules on a search path (earlier directories override later
// ones when several files define the same module), parses them on demand together with
// the modules they import, and resolves names to numeric OIDs. It is safe for
// concurrent use.
type Loader struct {
paths []string
mu sync.Mutex
indexed bool
index map[string][]string // module name -> candidate files, best first
files map[string][]*Module
mods map[string]*Module
loading map[string]bool
byOID map[string]*Object
byName map[string][]*Object
types map[string][]*typeDef // global fallback for type names
typeMods map[*typeDef]*Module
noDesc bool
}
// NewLoader returns a Loader that searches the given directories recursively.
// Nothing is read until the first call that needs a module.
func NewLoader(searchPath ...string) *Loader {
return &Loader{
paths: searchPath,
files: map[string][]*Module{},
mods: map[string]*Module{},
loading: map[string]bool{},
byOID: map[string]*Object{},
byName: map[string][]*Object{},
types: map[string][]*typeDef{},
typeMods: map[*typeDef]*Module{},
}
}
// DropDescriptions makes the Loader discard DESCRIPTION text, which saves a lot
// of memory when loading thousands of modules. Call it before the first Load.
func (l *Loader) DropDescriptions() { l.noDesc = true }
// Modules returns the names of every module found on the search path, sorted.
func (l *Loader) Modules() ([]string, error) {
l.mu.Lock()
defer l.mu.Unlock()
if err := l.buildIndex(); err != nil {
return nil, err
}
out := make([]string, 0, len(l.index))
for n := range l.index {
out = append(out, n)
}
sort.Strings(out)
return out, nil
}
// File returns the file chosen for a module, or "" when it is not on the path.
func (l *Loader) File(module string) string {
l.mu.Lock()
defer l.mu.Unlock()
if l.buildIndex() != nil {
return ""
}
if c := l.index[module]; len(c) > 0 {
return c[0]
}
return ""
}
// ErrNotFound is returned (wrapped) when a module or object does not exist.
var ErrNotFound = errors.New("mib: not found")
// Load parses a module and everything it imports. Imported modules that are not
// on the search path are recorded in Module.Missing rather than failing the
// load; names that depend on them stay unresolved.
func (l *Loader) Load(module string) (*Module, error) {
l.mu.Lock()
defer l.mu.Unlock()
return l.load(module)
}
func (l *Loader) load(name string) (*Module, error) {
if m, ok := l.mods[name]; ok {
return m, nil
}
if err := l.buildIndex(); err != nil {
return nil, err
}
m, err := l.parseCandidates(name, l.index[name])
if err != nil {
return nil, err
}
l.register(m)
l.loading[name] = true
err = l.loadImports(m)
delete(l.loading, name)
if err != nil {
delete(l.mods, name)
return nil, err
}
l.finalize(m)
return m, nil
}
// parseCandidates returns the first parsable candidate file's definition of module name.
func (l *Loader) parseCandidates(name string, cands []string) (*Module, error) {
var firstErr error
for _, f := range cands {
mods, err := l.parseFile(f)
if err != nil {
if firstErr == nil {
firstErr = err
}
continue
}
for _, x := range mods {
if x.Name == name {
return x, nil
}
}
}
if firstErr == nil {
firstErr = fmt.Errorf("%w: module %s", ErrNotFound, name)
}
return nil, firstErr
}
// loadImports loads what m imports. A missing import is recorded in m.Missing;
// one that exists but does not parse is an error.
func (l *Loader) loadImports(m *Module) error {
for _, imp := range m.Imports {
if l.loading[imp.Module] {
continue
}
if _, err := l.load(imp.Module); err != nil {
if errors.Is(err, ErrNotFound) {
m.Missing = appendUnique(m.Missing, imp.Module)
continue
}
return fmt.Errorf("mib: %s imports %s: %w", m.Name, imp.Module, err)
}
}
return nil
}
func appendUnique(s []string, v string) []string {
for _, x := range s {
if x == v {
return s
}
}
return append(s, v)
}
func (l *Loader) parseFile(path string) ([]*Module, error) {
if m, ok := l.files[path]; ok {
return m, nil
}
src, err := os.ReadFile(path)
if err != nil {
return nil, err
}
mods, err := parse(path, src, !l.noDesc)
if err != nil {
return nil, err
}
l.files[path] = mods
return mods, nil
}
// register makes a module's names visible for global fallback lookups.
func (l *Loader) register(m *Module) {
l.mods[m.Name] = m
for _, o := range m.Objects {
l.byName[o.Name] = append(l.byName[o.Name], o)
}
for _, t := range m.types {
l.types[t.name] = append(l.types[t.name], t)
l.typeMods[t] = m
}
}
var roots = map[string]int{"iso": 1, "ccitt": 0, "joint-iso-ccitt": 2}
// wellKnown covers the SMI roots for modules whose imports are missing.
var wellKnown = map[string]string{
"iso": "1", "org": "1.3", "dod": "1.3.6", "internet": "1.3.6.1", "directory": "1.3.6.1.1",
"mgmt": "1.3.6.1.2", "mib-2": "1.3.6.1.2.1", "transmission": "1.3.6.1.2.1.10", "experimental": "1.3.6.1.3",
"private": "1.3.6.1.4", "enterprises": "1.3.6.1.4.1", "security": "1.3.6.1.5", "snmpV2": "1.3.6.1.6",
"snmpModules": "1.3.6.1.6.3", "snmpDomains": "1.3.6.1.6.1", "snmpProxys": "1.3.6.1.6.2",
"zeroDotZero": "0.0",
}
// lookupName finds a definition visible from module m: its own, then its
// imports (transitively), then the well-known SMI names, then any loaded module.
func (l *Loader) lookupName(m *Module, name string) *Object {
if mod, n, ok := strings.Cut(name, "."); ok {
if x, err := l.load(mod); err == nil {
if o := l.lookupIn(x, n, map[*Module]bool{}); o != nil {
return o
}
}
name = n
}
if o := l.lookupIn(m, name, map[*Module]bool{}); o != nil {
return o
}
if s, ok := wellKnown[name]; ok {
return &Object{Name: name, Module: "SNMPv2-SMI", OID: s, state: 2, Kind: KindNode}
}
if c := l.byName[name]; len(c) > 0 {
return c[0]
}
return nil
}
func (l *Loader) lookupIn(m *Module, name string, seen map[*Module]bool) *Object {
if seen[m] {
return nil
}
seen[m] = true
if o := m.byName[name]; o != nil {
return o
}
// prefer the module named in the import list
for _, imp := range m.Imports {
for _, n := range imp.Names {
if n == name {
if x := l.mods[imp.Module]; x != nil {
if o := l.lookupIn(x, name, seen); o != nil {
return o
}
}
}
}
}
return nil
}
// oidOf computes (and memoises) an object's numeric OID.
func (l *Loader) oidOf(o *Object) (string, error) {
switch o.state {
case 2:
return o.OID, o.rerr
case 1:
return "", fmt.Errorf("circular definition of %s", o.Name)
}
o.state = 1
oid, err := l.compute(o)
o.state = 2
o.OID, o.rerr = oid, err
return oid, err
}
func (l *Loader) compute(o *Object) (string, error) {
var parts []string
for i, e := range o.def {
var part string
var err error
switch {
case e.name == "" || (i > 0 && e.hasNum):
part = strconv.Itoa(e.num)
case i == 0:
part, err = l.firstArc(o, e)
default: // a bare name after the first: its last arc
part, err = l.laterArc(o, e)
}
if err != nil {
return "", err
}
parts = append(parts, part)
}
return strings.Join(parts, "."), nil
}
// firstArc resolves the leading named element of an OID: an SMI root or the full OID of its parent.
func (l *Loader) firstArc(o *Object, e oidElem) (string, error) {
if r, ok := roots[e.name]; ok {
return strconv.Itoa(r), nil
}
t := l.lookupName(o.mod, e.name)
if t == nil {
return "", fmt.Errorf("%s: unknown parent %q", o.Name, e.name)
}
s, err := l.oidOf(t)
if err != nil {
return "", err
}
if s == "" {
return "", fmt.Errorf("%s: parent %q has no OID", o.Name, e.name)
}
return s, nil
}
// laterArc resolves a bare name after the first element to its last arc.
func (l *Loader) laterArc(o *Object, e oidElem) (string, error) {
t := l.lookupName(o.mod, e.name)
if t == nil {
return "", fmt.Errorf("%s: unknown component %q", o.Name, e.name)
}
s, err := l.oidOf(t)
if err != nil || s == "" {
return "", fmt.Errorf("%s: component %q unresolved", o.Name, e.name)
}
return s[strings.LastIndexByte(s, '.')+1:], nil
}
// finalize resolves every object's OID and type information and classifies rows and columns.
func (l *Loader) finalize(m *Module) {
local := l.resolveObjects(m)
parent := func(oid string) *Object {
i := strings.LastIndexByte(oid, '.')
if i < 0 {
return nil
}
return local[oid[:i]]
}
linkRows(m, parent)
linkColumns(m, parent)
// A scalar-looking object with children is just a node (rare, e.g. aggregate without SYNTAX).
for _, o := range m.Objects {
if o.Kind == KindScalar && o.syn == nil {
o.Kind = KindNode
}
}
}
// linkRows names each row's table; it must run before linkColumns, which copies that name.
func linkRows(m *Module, parent func(string) *Object) {
for _, o := range m.Objects {
if o.OID == "" || o.Kind != KindRow {
continue
}
if t := parent(o.OID); t != nil && t.Kind == KindTable {
o.Table = t.Name
}
}
}
// linkColumns turns a scalar whose parent is a row into a column of that row's table.
func linkColumns(m *Module, parent func(string) *Object) {
for _, o := range m.Objects {
if o.OID == "" || o.Kind != KindScalar {
continue
}
if r := parent(o.OID); r != nil && r.Kind == KindRow {
o.Kind, o.Table = KindColumn, r.Table
}
}
}
// resolveObjects computes every object's OID and type info; it returns the module's objects keyed by OID.
func (l *Loader) resolveObjects(m *Module) map[string]*Object {
local := map[string]*Object{}
for _, o := range m.Objects {
oid, err := l.oidOf(o)
if err != nil || oid == "" {
m.Unresolved = append(m.Unresolved, fmt.Sprintf("%s: %v", o.Name, err))
continue
}
local[oid] = o
if _, dup := l.byOID[oid]; !dup {
l.byOID[oid] = o
}
if o.syn != nil {
rt := l.resolveSyntax(m, o.syn)
o.BaseType, o.TC, o.DisplayHint, o.Enum, o.Range, o.Size = rt.base, rt.tc, rt.hint, rt.enum, rt.rng, rt.size
if o.BaseType == "SEQUENCE" || o.BaseType == "SEQUENCE OF" || o.BaseType == "CHOICE" {
o.BaseType, o.TC = "", ""
}
}
}
return local
}
// Object resolves "MODULE::name" (loading the module) or a bare name (searched in
// the modules loaded so far).
func (l *Loader) Object(name string) (*Object, error) {
l.mu.Lock()
defer l.mu.Unlock()
if mod, n, ok := strings.Cut(name, "::"); ok {
m, err := l.load(mod)
if err != nil {
return nil, err
}
o := m.byName[n]
if o == nil {
return nil, fmt.Errorf("%w: %s::%s", ErrNotFound, mod, n)
}
if o.OID == "" {
return nil, fmt.Errorf("mib: %s::%s: OID unresolved: %w", mod, n, o.rerr)
}
return o, nil
}
if c := l.byName[name]; len(c) > 0 {
for _, o := range c {
if o.OID != "" {
return o, nil
}
}
}
return nil, fmt.Errorf("%w: %s (not in a loaded module; use MODULE::name)", ErrNotFound, name)
}
// ByOID returns the loaded object with exactly this numeric OID (a leading dot is ignored).
func (l *Loader) ByOID(oid string) (*Object, error) {
l.mu.Lock()
defer l.mu.Unlock()
o, ok := l.byOID[strings.TrimPrefix(oid, ".")]
if !ok {
return nil, fmt.Errorf("%w: OID %s (no loaded module defines it)", ErrNotFound, oid)
}
return o, nil
}

View File

@@ -0,0 +1,185 @@
package mib
import (
"bytes"
"fmt"
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"sync"
)
type scanned struct {
file string
names []string
}
var (
scanMu sync.Mutex
scanCache = map[string][]scanned{} // cleaned dir -> scan result
)
// scanDir lists the module names defined by every file under root. Results are
// cached for the life of the process, so many Loaders over the same tree (the
// importer makes one per OS) pay for the scan once. Files that change while the
// process runs are not rescanned.
func scanDir(root string) ([]scanned, error) {
root = filepath.Clean(root)
scanMu.Lock()
defer scanMu.Unlock()
if r, ok := scanCache[root]; ok {
return r, nil
}
var files []string
err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if d.IsDir() || strings.EqualFold(filepath.Ext(p), ".md") {
return nil
}
files = append(files, p)
return nil
})
if err != nil {
return nil, fmt.Errorf("mib: search path %s: %w", root, err)
}
res := make([]scanned, len(files))
var wg sync.WaitGroup
work := make(chan int)
for w := 0; w < 8; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range work {
b, err := os.ReadFile(files[i])
if err != nil {
res[i].file = files[i]
continue
}
res[i] = scanned{files[i], scanModuleNames(b)}
}
}()
}
for i := range files {
work <- i
}
close(work)
wg.Wait()
scanCache[root] = res
return res, nil
}
// buildIndex scans every file under the search path for "<Name> DEFINITIONS ::= BEGIN".
// The file name is not trusted: MIB files are often named differently from the module.
func (l *Loader) buildIndex() error {
if l.indexed {
return nil
}
rootOf := map[string]int{} // file -> position of its search-path entry
idx := map[string][]string{}
for ri, root := range l.paths {
res, err := scanDir(root)
if err != nil {
return err
}
for _, f := range res {
if _, dup := rootOf[f.file]; dup {
continue
}
rootOf[f.file] = ri
for _, n := range f.names {
idx[n] = append(idx[n], f.file)
}
}
}
for n, c := range idx {
idx[n] = sortCandidates(c, rootOf)
}
l.index, l.indexed = idx, true
return nil
}
// sortCandidates orders the files defining one module best first: earlier
// search-path entries win, then shallower paths, then name. Duplicates (a module
// defined twice in one file) are dropped.
func sortCandidates(c []string, rootOf map[string]int) []string {
sort.SliceStable(c, func(i, j int) bool {
if ri, rj := rootOf[c[i]], rootOf[c[j]]; ri != rj {
return ri < rj
}
di, dj := strings.Count(c[i], string(filepath.Separator)), strings.Count(c[j], string(filepath.Separator))
if di != dj {
return di < dj
}
return c[i] < c[j]
})
out := c[:0]
for k, f := range c {
if k == 0 || f != c[k-1] {
out = append(out, f)
}
}
return out
}
// scanModuleNames finds the module names defined in a file without lexing it.
func scanModuleNames(b []byte) []string {
var names []string
key := []byte("DEFINITIONS")
off := 0
for {
k := bytes.Index(b[off:], key)
if k < 0 {
return names
}
pos := off + k
off = pos + len(key)
// must be followed by ::= soon, and not be the tail of a longer word
tail := b[off:min(len(b), off+60)]
if !bytes.Contains(tail, []byte("::=")) || pos > 0 && isIdentByte(b[pos-1]) {
continue
}
if name, ok := moduleNameBefore(b, pos); ok {
names = append(names, name)
}
}
}
// moduleNameBefore reads the module name that precedes the DEFINITIONS keyword
// at pos, skipping an optional { ... } OID group. It rejects names on a
// commented line and the keywords BEGIN/END.
func moduleNameBefore(b []byte, pos int) (string, bool) {
j := skipSpaceBack(b, pos-1)
if j >= 0 && b[j] == '}' {
for j >= 0 && b[j] != '{' {
j--
}
j = skipSpaceBack(b, j-1)
}
end := j + 1
for j >= 0 && isIdentByte(b[j]) {
j--
}
if end-(j+1) < 1 {
return "", false
}
name := string(b[j+1 : end])
ls := bytes.LastIndexByte(b[:j+1], '\n') + 1
if bytes.Contains(b[ls:j+1], []byte("--")) || name == "BEGIN" || name == "END" {
return "", false
}
return name, true
}
// skipSpaceBack returns the index of the last non-space byte at or before j.
func skipSpaceBack(b []byte, j int) int {
for j >= 0 && (b[j] == ' ' || b[j] == '\t' || b[j] == '\r' || b[j] == '\n') {
j--
}
return j
}
func isIdentByte(c byte) bool { return isLetter(c) || isDigit(c) || c == '-' }

View File

@@ -0,0 +1,125 @@
package mib
import "slices"
// builtin maps a type name to its canonical base type.
func builtin(name string) (string, bool) {
switch name {
case "INTEGER", "Integer32", "Unsigned32", "Gauge32", "Counter32", "Counter64", "TimeTicks",
"IpAddress", "Opaque", "BITS":
return name, true
case BaseOctets, BaseOID:
return name, true
case "Counter":
return BaseCounter32, true
case "Gauge":
return BaseGauge32, true
case "NetworkAddress":
return BaseIPAddress, true
case "UInteger32":
return BaseUnsigned32, true
}
return "", false
}
// fallbackTC knows the commonest textual conventions for modules whose SNMPv2-TC import is missing.
var fallbackTC = map[string][2]string{
"DisplayString": {BaseOctets, "255a"}, "PhysAddress": {BaseOctets, "1x:"}, "MacAddress": {BaseOctets, "1x:"},
"TruthValue": {BaseInteger, ""}, "TimeStamp": {BaseTimeTicks, ""}, "TimeInterval": {BaseInteger32, ""},
"DateAndTime": {BaseOctets, "2d-1d-1d,1d:1d:1d.1d,1a1d:1d"}, "RowStatus": {BaseInteger, ""},
"StorageType": {BaseInteger, ""}, "TestAndIncr": {BaseInteger32, ""}, "AutonomousType": {BaseOID, ""},
}
type resolvedType struct {
base, tc, hint, rng, size string
enum map[int]string
}
func (l *Loader) lookupType(m *Module, name string) (*typeDef, *Module) {
if t, tm := l.typeVisibleFrom(m, name, map[*Module]bool{}); t != nil {
return t, tm
}
if c := l.types[name]; len(c) > 0 {
return c[0], l.typeMods[c[0]]
}
return nil, nil
}
// typeVisibleFrom finds a type defined in x or reached through the import lists.
func (l *Loader) typeVisibleFrom(x *Module, name string, seen map[*Module]bool) (*typeDef, *Module) {
if seen[x] {
return nil, nil
}
seen[x] = true
if t := x.types[name]; t != nil {
return t, x
}
for _, imp := range x.Imports {
y := l.mods[imp.Module]
if y == nil || !slices.Contains(imp.Names, name) {
continue
}
if t, tm := l.typeVisibleFrom(y, name, seen); t != nil {
return t, tm
}
}
return nil, nil
}
func (l *Loader) resolveSyntax(m *Module, s *syntax) resolvedType {
rt := resolvedType{enum: s.enum, rng: s.rng, size: s.size}
name, mod := s.name, m
for depth := 0; depth < 20; depth++ {
if b, ok := builtin(name); ok {
rt.base = b
return rt
}
if name == "SEQUENCE OF" || name == "SEQUENCE" || name == "CHOICE" {
rt.base = name
return rt
}
td, tm := l.lookupType(mod, name)
if td == nil {
rt.applyFallback(name)
return rt
}
rt.inherit(name, td)
name, mod = td.syn.name, tm
}
return rt
}
// applyFallback fills in a commonly used textual convention whose defining module is missing.
func (rt *resolvedType) applyFallback(name string) {
if fb, ok := fallbackTC[name]; ok {
rt.base = fb[0]
if rt.tc == "" {
rt.tc = name
}
if rt.hint == "" {
rt.hint = fb[1]
}
}
if rt.tc == "" && rt.base == "" {
rt.tc = name
}
}
// inherit takes whatever the outer type left unset from the type definition td named name.
func (rt *resolvedType) inherit(name string, td *typeDef) {
if rt.tc == "" {
rt.tc = name
}
if rt.hint == "" {
rt.hint = td.hint
}
if rt.enum == nil {
rt.enum = td.syn.enum
}
if rt.rng == "" {
rt.rng = td.syn.rng
}
if rt.size == "" {
rt.size = td.syn.size
}
}

160
internal/mib/mib_test.go Normal file
View File

@@ -0,0 +1,160 @@
package mib
import (
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"testing"
"time"
)
// bundledDir returns <repo>/mibs.
func bundledDir(t testing.TB) string {
t.Helper()
d, err := filepath.Abs(filepath.Join("..", "..", "mibs"))
if err != nil {
t.Fatal(err)
}
if _, err := os.Stat(d); err != nil {
t.Skipf("bundled MIBs not present: %v", err)
}
return d
}
// parseAllowList names bundled files that cannot be parsed, with the reason for each.
// Keep this empty unless a file is genuinely malformed.
var parseAllowList = map[string]string{}
func bundledFiles(t testing.TB, dir string) []string {
var files []string
err := filepath.WalkDir(dir, func(p string, d os.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && !strings.EqualFold(filepath.Ext(p), ".md") {
files = append(files, p)
}
return nil
})
if err != nil {
t.Fatal(err)
}
sort.Strings(files)
return files
}
func TestParseAllBundledMIBs(t *testing.T) {
dir := bundledDir(t)
files := bundledFiles(t, dir)
type result struct {
err error
mods int
objs int
}
res := make([]result, len(files))
var wg sync.WaitGroup
work := make(chan int)
for w := 0; w < 8; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for i := range work {
src, err := os.ReadFile(files[i])
if err != nil {
res[i].err = err
continue
}
mods, err := parse(files[i], src, false)
res[i].err = err
res[i].mods = len(mods)
for _, m := range mods {
res[i].objs += len(m.Objects)
}
}
}()
}
for i := range files {
work <- i
}
close(work)
wg.Wait()
var failed []string
mods, objs := 0, 0
for i, f := range files {
rel, _ := filepath.Rel(dir, f)
mods += res[i].mods
objs += res[i].objs
if res[i].err != nil {
if reason, ok := parseAllowList[rel]; ok {
t.Logf("allow-listed %s: %s (%v)", rel, reason, res[i].err)
continue
}
failed = append(failed, fmt.Sprintf("%s: %v", rel, res[i].err))
}
}
t.Logf("parsed %d files, %d modules, %d objects; %d failures", len(files), mods, objs, len(failed))
if len(failed) > 0 {
t.Errorf("%d of %d files failed to parse:\n%s", len(failed), len(files), strings.Join(failed, "\n"))
}
}
// TestLoadAllBundledModules loads every module by name through a Loader (imports
// and OID resolution included) and reports how many objects resolved.
func TestLoadAllBundledModules(t *testing.T) {
if testing.Short() {
t.Skip("short")
}
dir := bundledDir(t)
l := NewLoader(dir)
l.DropDescriptions()
names, err := l.Modules()
if err != nil {
t.Fatal(err)
}
var fails []string
objs, unresolved, missing := 0, 0, map[string]bool{}
for _, n := range names {
m, err := l.Load(n)
if err != nil {
fails = append(fails, fmt.Sprintf("%s: %v", n, err))
continue
}
objs += len(m.Objects)
unresolved += len(m.Unresolved)
for _, x := range m.Missing {
missing[x] = true
}
}
t.Logf("%d modules loaded, %d objects, %d unresolved OIDs, %d imported modules absent from the tree",
len(names), objs, unresolved, len(missing))
if len(fails) > 0 {
t.Errorf("%d modules failed:\n%s", len(fails), strings.Join(fails, "\n"))
}
}
func TestIndexAndLoadPerformance(t *testing.T) {
dir := bundledDir(t)
l := NewLoader(dir)
t0 := time.Now()
if _, err := l.Modules(); err != nil {
t.Fatal(err)
}
idx := time.Since(t0)
t0 = time.Now()
m, err := l.Load("IF-MIB")
if err != nil {
t.Fatal(err)
}
load := time.Since(t0)
t.Logf("index %v, IF-MIB+deps %v (%d objects, missing %v)", idx, load, len(m.Objects), m.Missing)
if idx > 3*time.Second {
t.Errorf("index took %v, want < 3s", idx)
}
if load > 100*time.Millisecond {
t.Errorf("IF-MIB load took %v, want < 100ms", load)
}
}

View File

@@ -0,0 +1,126 @@
package mib
import (
"reflect"
"strings"
"testing"
)
func TestHandWrittenModule(t *testing.T) {
l := NewLoader("testdata")
m, err := l.Load("TEST-MIB")
if err != nil {
t.Fatal(err)
}
if len(m.Unresolved) != 0 {
t.Fatalf("unresolved: %v", m.Unresolved)
}
if !reflect.DeepEqual(m.Missing, []string{"SNMPv2-SMI", "SNMPv2-TC"}) {
t.Errorf("missing = %v", m.Missing)
}
get := func(n string) *Object {
o, err := l.Object("TEST-MIB::" + n)
if err != nil {
t.Fatal(err)
}
return o
}
temp := get("testTemp")
if temp.OID != "1.3.6.1.4.1.99999.1.1" || temp.Kind != KindScalar || temp.BaseType != BaseInteger32 ||
temp.TC != "Tenths" || temp.DisplayHint != "d-1" || temp.Units != "degrees Celsius" || temp.Range != "0..1000" {
t.Errorf("testTemp = %+v", temp)
}
mode := get("testMode")
if mode.BaseType != BaseInteger || mode.Enum[2] != "on" || mode.Enum[-1] != "broken" || mode.Access != "read-write" {
t.Errorf("testMode = %+v", mode)
}
if k := get("testTable").Kind; k != KindTable {
t.Errorf("table kind %v", k)
}
row := get("testEntry")
if row.Kind != KindRow || row.Table != "testTable" || !reflect.DeepEqual(row.Index, []string{"testIndex"}) {
t.Errorf("row = %+v", row)
}
name := get("testName")
if name.Kind != KindColumn || name.Table != "testTable" || name.TC != "DisplayString" || name.BaseType != BaseOctets || name.Size != "0..64" {
t.Errorf("testName = %+v", name)
}
if o := get("testOctets"); o.BaseType != BaseCounter64 || o.OID != "1.3.6.1.4.1.99999.1.3.1.3" {
t.Errorf("testOctets = %+v", o)
}
if o := get("testNotif"); o.Kind != KindNotification || o.OID != "1.3.6.1.4.1.99999.2" {
t.Errorf("testNotif = %+v", o)
}
if o, err := l.ByOID(".1.3.6.1.4.1.99999.1.1"); err != nil || o.Name != "testTemp" {
t.Errorf("ByOID = %v, %v", o, err)
}
if o, err := l.Object("testMode"); err != nil || o.Name != "testMode" {
t.Errorf("bare name = %v, %v", o, err)
}
if _, err := l.Object("TEST-MIB::nope"); err == nil {
t.Error("expected not found")
}
if _, err := l.Load("NO-SUCH-MIB"); err == nil {
t.Error("expected error")
}
}
func TestTrapTypeAndComments(t *testing.T) {
src := `FOO-MIB DEFINITIONS ::= BEGIN
IMPORTS enterprises FROM RFC1155-SMI TRAP-TYPE FROM RFC-1215;
foo OBJECT IDENTIFIER ::= { enterprises 7 } -- trailing
-- full line comment ::= nonsense
fooDown TRAP-TYPE
ENTERPRISE foo
VARIABLES { sysDescr }
DESCRIPTION "down -- not a comment"
::= 3
bar OBJECT IDENTIFIER ::= { foo 1 } -- c1 -- baz OBJECT IDENTIFIER ::= { foo 2 }
STRANGE-MACRO MACRO ::= BEGIN TYPE NOTATION ::= "x" VALUE NOTATION ::= value(VALUE OBJECT IDENTIFIER) END
unknownThing STRANGE-MACRO FOO "bar" ::= { foo 3 }
END`
mods, err := Parse("inline", []byte(src))
if err != nil {
t.Fatal(err)
}
m := mods[0]
var names []string
for _, o := range m.Objects {
names = append(names, o.Name)
}
if got := strings.Join(names, ","); got != "foo,fooDown,bar,baz,unknownThing" {
t.Errorf("objects = %s", got)
}
}
func TestBundledIFMIB(t *testing.T) {
l := NewLoader(bundledDir(t))
for _, c := range []struct {
name, oid, base, tc, hint, kind string
}{
{"IF-MIB::ifHCInOctets", "1.3.6.1.2.1.31.1.1.1.6", BaseCounter64, "", "", "column"},
{"IF-MIB::ifInOctets", "1.3.6.1.2.1.2.2.1.10", BaseCounter32, "", "", "column"},
{"IF-MIB::ifAlias", "1.3.6.1.2.1.31.1.1.1.18", BaseOctets, "DisplayString", "255a", "column"},
{"IF-MIB::ifOperStatus", "1.3.6.1.2.1.2.2.1.8", BaseInteger, "", "", "column"},
{"SNMPv2-MIB::sysUpTime", "1.3.6.1.2.1.1.3", BaseTimeTicks, "", "", "scalar"},
{"IF-MIB::ifTable", "1.3.6.1.2.1.2.2", "", "", "", "table"},
{"IF-MIB::ifEntry", "1.3.6.1.2.1.2.2.1", "", "", "", "row"},
{"IF-MIB::linkDown", "1.3.6.1.6.3.1.1.5.3", "", "", "", "notification"},
} {
o, err := l.Object(c.name)
if err != nil {
t.Errorf("%s: %v", c.name, err)
continue
}
if o.OID != c.oid || o.BaseType != c.base || o.TC != c.tc || o.DisplayHint != c.hint || o.Kind.String() != c.kind {
t.Errorf("%s = oid %s base %q tc %q hint %q kind %s", c.name, o.OID, o.BaseType, o.TC, o.DisplayHint, o.Kind)
}
}
o, _ := l.Object("IF-MIB::ifOperStatus")
if o.Enum[1] != "up" || o.Enum[2] != "down" || len(o.Enum) != 7 {
t.Errorf("ifOperStatus enum = %v", o.Enum)
}
if o, _ := l.Object("IF-MIB::ifInOctets"); o.Table != "ifTable" {
t.Errorf("ifInOctets table = %q", o.Table)
}
}

Some files were not shown because too many files have changed in this diff Show More