Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1ed97a540d | |||
| 5283921d6d | |||
| e7c70ce466 | |||
| 287fbb98bd | |||
| cce37313f1 | |||
| 2f6dcd5754 | |||
| f7b394585b | |||
| a037b406cb | |||
| 61b3de28dd | |||
| cc9043b014 | |||
| 4252b342fc | |||
| a71e3746f2 | |||
| 22c2a6438c | |||
| 76a599d4e1 | |||
| b250f63608 | |||
| b32c74c29a |
11
.github/workflows/ci.yml
vendored
@@ -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
@@ -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
|
||||
14
Makefile
@@ -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 ./...
|
||||
|
||||
|
||||
110
cmd/nmon-librenms-import/main.go
Normal 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
|
||||
}
|
||||
42
cmd/nmon-librenms-import/main_test.go
Normal 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
@@ -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()
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
BIN
docs/screenshots/M3-mock/device-32bit-warning.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
docs/screenshots/M3-mock/device-48-graphs-full.png
Normal file
|
After Width: | Height: | Size: 581 KiB |
BIN
docs/screenshots/M3-mock/device-48-graphs.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
docs/screenshots/M3-mock/device-down.png
Normal file
|
After Width: | Height: | Size: 137 KiB |
BIN
docs/screenshots/M3-mock/device-light.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/screenshots/M3-mock/device.png
Normal file
|
After Width: | Height: | Size: 138 KiB |
BIN
docs/screenshots/M3-mock/devices-light.png
Normal file
|
After Width: | Height: | Size: 183 KiB |
BIN
docs/screenshots/M3-mock/devices.png
Normal file
|
After Width: | Height: | Size: 184 KiB |
BIN
docs/screenshots/M3-mock/graph-1h.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
docs/screenshots/M3-mock/graph-1y-stitched.png
Normal file
|
After Width: | Height: | Size: 107 KiB |
BIN
docs/screenshots/M3-mock/graph-24h-light.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/screenshots/M3-mock/graph-24h.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
docs/screenshots/M3-mock/graph-30d.png
Normal file
|
After Width: | Height: | Size: 196 KiB |
BIN
docs/screenshots/M3-mock/graph-unreliable.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
docs/screenshots/M3-mock/interfaces.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
docs/screenshots/M3-mock/phone-device.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
docs/screenshots/M3-mock/phone-devices.png
Normal file
|
After Width: | Height: | Size: 60 KiB |
BIN
docs/screenshots/M3-mock/phone-graph-24h.png
Normal file
|
After Width: | Height: | Size: 52 KiB |
BIN
docs/screenshots/M3-mock/poller.png
Normal file
|
After Width: | Height: | Size: 120 KiB |
92
internal/alert/deliver.go
Normal 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
@@ -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
|
||||
}
|
||||
660
internal/alert/evaluator_test.go
Normal 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
|
||||
}
|
||||
150
internal/alert/integration_test.go
Normal 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
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
123
internal/api/reattach_test.go
Normal 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])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
313
internal/app/lifecycle_test.go
Normal 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
@@ -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
@@ -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
@@ -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 }
|
||||
51
internal/cliout/cliout_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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():
|
||||
|
||||
@@ -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 {
|
||||
|
||||
161
internal/config/config_test.go
Normal 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
@@ -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),
|
||||
})
|
||||
}
|
||||
200
internal/discovery/apply_interfaces.go
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
424
internal/discovery/collect.go
Normal 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, ":")
|
||||
}
|
||||
763
internal/discovery/discover_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
419
internal/discovery/matcher.go
Normal 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)
|
||||
}
|
||||
}
|
||||
217
internal/discovery/matcher_test.go
Normal 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")
|
||||
}
|
||||
66
internal/discovery/reattach.go
Normal 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
|
||||
}
|
||||
266
internal/discovery/scheduler.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
275
internal/discovery/scheduler_test.go
Normal 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))
|
||||
}
|
||||
154
internal/discovery/tables.go
Normal 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
|
||||
}
|
||||
220
internal/fixturegen/fixtures.go
Normal 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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
30
internal/fixturegen/notaccessible_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
415
internal/fixturegen/profile.go
Normal 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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
226
internal/fixtures/compare.go
Normal 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, "; ")
|
||||
}
|
||||
303
internal/fixtures/fixtures.go
Normal 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
|
||||
}
|
||||
323
internal/fixtures/fixtures_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
99
internal/fixtures/librenms_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
455
internal/librenms/discover.go
Normal 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
@@ -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
|
||||
}
|
||||
308
internal/librenms/import_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
172
internal/librenms/modules.go
Normal 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
@@ -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")
|
||||
}
|
||||
23
internal/librenms/testdata/lnms/mibs/acme/ACME-MIB
vendored
Normal 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
|
||||
15
internal/librenms/testdata/lnms/resources/definitions/os_detection/acme.yaml
vendored
Normal 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/'
|
||||
106
internal/librenms/testdata/lnms/resources/definitions/os_discovery/acme.yaml
vendored
Normal 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
@@ -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
@@ -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
@@ -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
|
||||
}
|
||||
185
internal/mib/loader_index.go
Normal 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 == '-' }
|
||||
125
internal/mib/loader_types.go
Normal 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
@@ -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)
|
||||
}
|
||||
}
|
||||
126
internal/mib/mib_unit_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||