Compare commits
17 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f85c3ef781 | |||
| 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:
|
||||
|
||||
3
.gitignore
vendored
@@ -1,5 +1,6 @@
|
||||
/nmon
|
||||
/nmon-arm64
|
||||
/nmon-devtools
|
||||
/internal/vmsup/bin/*.tar.gz
|
||||
/internal/vmsup/bin/victoria-metrics-*
|
||||
web/node_modules/
|
||||
@@ -8,3 +9,5 @@ web/dist/
|
||||
*.test
|
||||
coverage.out
|
||||
/nmon-librenms-import
|
||||
/internal/webui/dist/*
|
||||
!/internal/webui/dist/.gitkeep
|
||||
|
||||
153
.golangci.yml
Normal file
@@ -0,0 +1,153 @@
|
||||
# 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
|
||||
devtools-not-in-nmon:
|
||||
# profile_record.go (`nmon profile record`, M2) already uses the recording types and writer; nothing else may.
|
||||
files: ["**/cmd/nmon/**", "!**/cmd/nmon/profile_record.go", "!$test"]
|
||||
deny:
|
||||
- pkg: github.com/v10networks/nmon/internal/simagent
|
||||
desc: the simulator belongs to cmd/nmon-devtools and tests, never the shipped binary
|
||||
- pkg: github.com/v10networks/nmon/internal/fixturegen
|
||||
desc: the fixture generator belongs to cmd/nmon-devtools and tests, never the shipped binary
|
||||
devtools-no-core:
|
||||
files: ["**/cmd/nmon-devtools/**"]
|
||||
deny:
|
||||
- pkg: github.com/v10networks/nmon/internal/app
|
||||
desc: nmon-devtools talks to a running nmon over HTTP
|
||||
- pkg: github.com/v10networks/nmon/internal/api
|
||||
desc: nmon-devtools talks to a running nmon over HTTP
|
||||
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
|
||||
35
Makefile
@@ -1,24 +1,33 @@
|
||||
GO ?= go
|
||||
TAGS ?= vmembed
|
||||
LDFLAGS := -s -w
|
||||
VERSION ?= $(shell git describe --tags --always --dirty 2>/dev/null || echo dev)
|
||||
BUILD ?= $(shell git rev-parse --short HEAD 2>/dev/null || echo unknown)-$(shell date -u +%Y%m%d)
|
||||
LDFLAGS := -s -w -X github.com/v10networks/nmon/internal/buildinfo.Version=$(VERSION) -X github.com/v10networks/nmon/internal/buildinfo.Build=$(BUILD)
|
||||
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: devtools 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
|
||||
|
||||
fetch-vm:
|
||||
./scripts/fetch-vm.sh
|
||||
|
||||
# The UI is built first when web/ exists so embed.FS picks it up.
|
||||
# The UI is built first, then copied into internal/webui/dist for go:embed (git-ignored).
|
||||
web:
|
||||
@if [ -f web/package.json ]; then cd web && npm ci --no-audit --no-fund && npm run build; fi
|
||||
@if [ -f web/package.json ]; then \
|
||||
cd web && { [ -d node_modules ] || npm ci --no-audit --no-fund; } && npm run build && \
|
||||
cd .. && rm -rf internal/webui/dist/* && cp -r web/dist/. internal/webui/dist/ && touch internal/webui/dist/.gitkeep; \
|
||||
fi
|
||||
|
||||
build: fetch-vm
|
||||
build: fetch-vm web
|
||||
CGO_ENABLED=0 $(GO) build -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(BIN) ./cmd/nmon
|
||||
|
||||
build-arm64: fetch-vm
|
||||
# Developer tools (backfill, seed-demo) are a separate binary that is never shipped.
|
||||
devtools:
|
||||
CGO_ENABLED=0 $(GO) build -o nmon-devtools ./cmd/nmon-devtools
|
||||
|
||||
build-arm64: fetch-vm web
|
||||
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 $(GO) build -tags '$(TAGS)' -ldflags '$(LDFLAGS)' -o $(BIN)-arm64 ./cmd/nmon
|
||||
|
||||
importer:
|
||||
@@ -27,6 +36,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 ./...
|
||||
|
||||
@@ -47,4 +68,4 @@ gate-m1 gate-m2 gate-m3 gate-m4 gate-m5 gate-m6: gate-m%:
|
||||
./scripts/gate-m$*.sh
|
||||
|
||||
clean:
|
||||
rm -f $(BIN) $(BIN)-arm64 nmon-librenms-import
|
||||
rm -f $(BIN) $(BIN)-arm64 nmon-devtools nmon-librenms-import
|
||||
|
||||
162
cmd/nmon-devtools/backfill.go
Normal file
@@ -0,0 +1,162 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/metrics"
|
||||
)
|
||||
|
||||
// backfillConfig is what one `devtools backfill` run writes, for every interface in ifaces.
|
||||
type backfillConfig struct {
|
||||
hotURL string
|
||||
device string
|
||||
ifaces []string
|
||||
days int
|
||||
step time.Duration
|
||||
pattern string
|
||||
meanBps float64 // bits/s; 0 derives the mean from the live rate, else a stable per-interface value
|
||||
seed uint64
|
||||
hc *http.Client
|
||||
logf func(format string, a ...any)
|
||||
}
|
||||
|
||||
func backfillCmd(ctx context.Context, args []string) error {
|
||||
cfg := backfillConfig{hc: &http.Client{Timeout: 2 * time.Minute}, logf: func(f string, a ...any) { fmt.Printf(f+"\n", a...) }}
|
||||
fs := devFlagSet("backfill")
|
||||
var ifaces stringList
|
||||
var mbps float64
|
||||
fs.StringVar(&cfg.hotURL, "hot-url", "", "base URL of the hot VictoriaMetrics (required; see \"metric stores up\" in the serve log)")
|
||||
fs.StringVar(&cfg.device, "device", "", "device slug (the series' device label)")
|
||||
fs.Var(&ifaces, "iface", "interface ULID; repeat the flag or separate with commas")
|
||||
fs.IntVar(&cfg.days, "days", 31, "days of history to write, ending where the live data begins")
|
||||
fs.DurationVar(&cfg.step, "step", 60*time.Second, "sample spacing")
|
||||
fs.StringVar(&cfg.pattern, "pattern", patternDiurnal, "traffic shape: diurnal or flat")
|
||||
fs.Float64Var(&mbps, "mean-mbps", 0, "mean rate in Mbit/s; 0 follows the live rate when there is one")
|
||||
fs.Uint64Var(&cfg.seed, "seed", 1, "noise seed")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
cfg.ifaces, cfg.meanBps = ifaces, mbps*1e6
|
||||
if err := cfg.validate(); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, id := range cfg.ifaces {
|
||||
if err := cfg.backfillIface(ctx, id); err != nil {
|
||||
return fmt.Errorf("iface %s: %w", id, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *backfillConfig) validate() error {
|
||||
switch {
|
||||
case c.hotURL == "":
|
||||
return errors.New("--hot-url is required")
|
||||
case c.device == "" || len(c.ifaces) == 0:
|
||||
return errors.New("--device and at least one --iface are required")
|
||||
case c.days < 1 || c.step < time.Second:
|
||||
return errors.New("--days must be at least 1 and --step at least 1s")
|
||||
case c.pattern != patternDiurnal && c.pattern != patternFlat:
|
||||
return fmt.Errorf("unknown --pattern %q (diurnal or flat)", c.pattern)
|
||||
}
|
||||
c.hotURL = strings.TrimRight(c.hotURL, "/")
|
||||
return nil
|
||||
}
|
||||
|
||||
// liveSeries is what the hot store already holds for one series: the first
|
||||
// sample (where history must end and which value it must join) and the live rate.
|
||||
type liveSeries struct {
|
||||
ts time.Time
|
||||
val float64
|
||||
rate float64 // units per second between the first two samples; 0 when unknown
|
||||
ok bool
|
||||
}
|
||||
|
||||
// backfillIface writes in/out octet counters and the oper status for one interface.
|
||||
func (c *backfillConfig) backfillIface(ctx context.Context, iface string) error {
|
||||
n := c.days * int(24*time.Hour/c.step)
|
||||
var samples []metrics.Sample
|
||||
var end time.Time
|
||||
for d, dir := range []struct {
|
||||
metric string
|
||||
salt uint64
|
||||
}{{"ifHCInOctets", 0x1111}, {"ifHCOutOctets", 0x2222}} {
|
||||
live, err := c.probe(ctx, dir.metric, iface)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d == 0 {
|
||||
end = c.endTime(live)
|
||||
}
|
||||
samples = append(samples, c.counter(dir.metric, iface, end, n, live, c.seed^dir.salt)...)
|
||||
}
|
||||
status := 1.0
|
||||
if live, err := c.probe(ctx, "ifOperStatus", iface); err == nil && live.ok {
|
||||
status = live.val
|
||||
}
|
||||
for i := 0; i < n; i++ {
|
||||
ts := end.Add(-time.Duration(n-1-i) * c.step)
|
||||
samples = append(samples, metrics.New("ifOperStatus", status, ts, metrics.LabelDevice, c.device, metrics.LabelIface, iface))
|
||||
}
|
||||
if err := c.send(ctx, samples); err != nil {
|
||||
return err
|
||||
}
|
||||
c.logf("backfilled %s/%s: %d days at %s (%d samples)", c.device, iface, c.days, c.step, len(samples))
|
||||
return nil
|
||||
}
|
||||
|
||||
// endTime is one step before the first live sample, or one step ago when there is none.
|
||||
func (c *backfillConfig) endTime(live liveSeries) time.Time {
|
||||
if live.ok {
|
||||
return live.ts.Add(-c.step).Truncate(time.Millisecond)
|
||||
}
|
||||
return time.Now().Add(-c.step).Truncate(time.Millisecond)
|
||||
}
|
||||
|
||||
// counter builds one direction's history ending at end. When the live counter
|
||||
// is known and large enough, the history is shifted so its last value is where
|
||||
// the live counter was one step earlier and the join shows no reset.
|
||||
func (c *backfillConfig) counter(metric, iface string, end time.Time, n int, live liveSeries, salt uint64) []metrics.Sample {
|
||||
mean := c.meanBps / 8
|
||||
if mean == 0 && live.rate > 0 {
|
||||
mean = live.rate
|
||||
}
|
||||
if mean == 0 {
|
||||
mean = defaultMeanBytesPerSec(iface, salt)
|
||||
}
|
||||
start := end.Add(-time.Duration(n-1) * c.step)
|
||||
vals := counterSeries(c.pattern, start, n, c.step, mean, salt)
|
||||
var offset float64
|
||||
if live.ok {
|
||||
offset = live.val - mean*c.step.Seconds() - vals[n-1]
|
||||
if offset < 0 {
|
||||
c.logf("warning: %s %s: live counter %.3g is below the %.3g bytes of history; the join will look like a counter reset",
|
||||
c.device, metric, live.val, vals[n-1])
|
||||
offset = 0
|
||||
}
|
||||
}
|
||||
out := make([]metrics.Sample, n)
|
||||
for i, v := range vals {
|
||||
ts := start.Add(time.Duration(i) * c.step)
|
||||
out[i] = metrics.New(metric, math.Floor(v+offset), ts, metrics.LabelDevice, c.device, metrics.LabelIface, iface)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// probe reads the earliest samples of metric{iface=...} from the hot store.
|
||||
func (c *backfillConfig) probe(ctx context.Context, metric, iface string) (liveSeries, error) {
|
||||
q := url.Values{"match[]": {fmt.Sprintf(`%s{iface=%q}`, metric, iface)}}
|
||||
var raw bytes.Buffer
|
||||
if err := c.get(ctx, c.hotURL+"/api/v1/export?"+q.Encode(), &raw); err != nil {
|
||||
return liveSeries{}, err
|
||||
}
|
||||
return parseExport(raw.Bytes())
|
||||
}
|
||||
130
cmd/nmon-devtools/fleet.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/gosnmp/gosnmp"
|
||||
|
||||
"github.com/v10networks/nmon/internal/fixturegen"
|
||||
"github.com/v10networks/nmon/internal/profile"
|
||||
"github.com/v10networks/nmon/internal/simagent"
|
||||
)
|
||||
|
||||
// demoDevice is one simulated device of `devtools seed-demo`.
|
||||
type demoDevice struct {
|
||||
name, site, role string
|
||||
kind string // counters, big, router, ups, v1, down: what the gate and screenshots look for
|
||||
rec *simagent.Recording
|
||||
community string
|
||||
version string // v2c unless set
|
||||
hc bool // 64-bit counters, so history can be back-filled
|
||||
}
|
||||
|
||||
// counterBase is added to every simulated 64-bit octet counter so that the
|
||||
// back-filled history (about 3e13 bytes for 31 days at 100 Mbit/s) fits below
|
||||
// the live counter and the join shows no reset.
|
||||
const counterBase = 100_000_000_000_000
|
||||
|
||||
const (
|
||||
oidHCIn = "1.3.6.1.2.1.31.1.1.1.6."
|
||||
oidHCOut = "1.3.6.1.2.1.31.1.1.1.10."
|
||||
oidUp = "1.3.6.1.2.1.1.3.0"
|
||||
)
|
||||
|
||||
// animate returns a copy of rec whose 64-bit octet counters advance at a steady
|
||||
// per-port rate from counterBase and whose uptime ticks, so successive polls see
|
||||
// traffic. Counters that already advance keep their rate and only gain the base.
|
||||
func animate(rec *simagent.Recording) *simagent.Recording {
|
||||
recs := append([]simagent.Record(nil), rec.Records()...)
|
||||
for i := range recs {
|
||||
r := &recs[i]
|
||||
switch {
|
||||
case r.Type == gosnmp.Counter64 && (strings.HasPrefix(r.OID, oidHCIn) || strings.HasPrefix(r.OID, oidHCOut)):
|
||||
recs[i] = animateCounter(*r)
|
||||
case r.OID == oidUp && r.Dyn == nil:
|
||||
if v, ok := r.Value.(uint32); ok {
|
||||
r.Dyn, r.Value = &simagent.Dynamic{Rate: 100, Initial: uint64(v)}, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return simagent.NewRecording(recs)
|
||||
}
|
||||
|
||||
func animateCounter(r simagent.Record) simagent.Record {
|
||||
if r.Dyn != nil {
|
||||
d := *r.Dyn
|
||||
d.Initial += counterBase
|
||||
r.Dyn = &d
|
||||
return r
|
||||
}
|
||||
k, _ := strconv.Atoi(r.OID[strings.LastIndexByte(r.OID, '.')+1:])
|
||||
dir := 1
|
||||
if strings.HasPrefix(r.OID, oidHCOut) {
|
||||
dir = 2
|
||||
}
|
||||
mbps := 1 + (k*(31+7*dir))%90
|
||||
r.Dyn = &simagent.Dynamic{Rate: float64(mbps) * 125_000, Initial: counterBase + uint64(k)*1_000_000_000}
|
||||
r.Value = nil
|
||||
return r
|
||||
}
|
||||
|
||||
// profileDevice builds a device that a shipped profile matches, with traffic.
|
||||
func profileDevice(set *profile.Set, prof, name, site, role, kind string, ports int) (demoDevice, error) {
|
||||
spec, ok := fixturegen.SpecFor(prof)
|
||||
if !ok {
|
||||
return demoDevice{}, fmt.Errorf("no fixture spec for profile %q", prof)
|
||||
}
|
||||
res, ok := set.Lookup(prof)
|
||||
if !ok {
|
||||
return demoDevice{}, fmt.Errorf("no profile %q", prof)
|
||||
}
|
||||
spec.SysName = name
|
||||
if ports > 0 {
|
||||
spec.Ports = ports
|
||||
}
|
||||
rec, err := fixturegen.FromProfile(res, spec)
|
||||
if err != nil {
|
||||
return demoDevice{}, fmt.Errorf("%s: %w", name, err)
|
||||
}
|
||||
return demoDevice{name: name, site: site, role: role, kind: kind, rec: animate(rec), community: "public", hc: true}, nil
|
||||
}
|
||||
|
||||
// buildFleet is the small network used for live screenshots: a 48-port switch,
|
||||
// routers, a firewall, a UPS, one 32-bit device, the counters fixture and one
|
||||
// device whose agent is stopped after seeding, so it reads as down.
|
||||
func buildFleet(set *profile.Set, countersRec string) ([]demoDevice, error) {
|
||||
counters, err := simagent.LoadRecording(countersRec)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fleet := []demoDevice{{
|
||||
name: "yyz1-edge1", site: "yyz1", role: "edge", kind: "counters", rec: animate(counters), community: "public", hc: true,
|
||||
}}
|
||||
for _, p := range []struct {
|
||||
prof, name, site, role, kind string
|
||||
ports int
|
||||
}{
|
||||
{"cisco-ios", "yyz1-acc01", "yyz1", "access", "big", 48},
|
||||
{"cisco-ios", "yyz1-acc02", "yyz1", "access", "router", 24},
|
||||
{"cisco-ios", "ott1-acc02", "ott1", "access", "down", 24}, // its agent is stopped once seeding is done
|
||||
{"juniper-junos", "yyz1-core1", "yyz1", "core", "router", 0},
|
||||
{"arista-eos", "yul1-core1", "yul1", "core", "router", 0},
|
||||
{"mikrotik-routeros", "ott1-gw1", "ott1", "gateway", "router", 0},
|
||||
{"fortigate", "yyz1-fw1", "yyz1", "firewall", "router", 0},
|
||||
{"apc-ups", "yyz1-ups1", "yyz1", "power", "ups", 0},
|
||||
} {
|
||||
d, err := profileDevice(set, p.prof, p.name, p.site, p.role, p.kind, p.ports)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
fleet = append(fleet, d)
|
||||
}
|
||||
// 32-bit counters on 1 Gbit/s ports: the API flags them unreliable and the UI never graphs them.
|
||||
v1 := fixturegen.Generic(fixturegen.Options{Ports: 12, SysName: "yul1-acc03", Counters: true, NamePattern: "Fa0/%d"})
|
||||
fleet = append(fleet, demoDevice{
|
||||
name: "yul1-acc03", site: "yul1", role: "access", kind: "v1", rec: v1, community: "nmon-v1", version: "v1",
|
||||
})
|
||||
return fleet, nil
|
||||
}
|
||||
119
cmd/nmon-devtools/main.go
Normal file
@@ -0,0 +1,119 @@
|
||||
// Command nmon-devtools holds developer helpers for the gates and for screenshots: backfill (synthetic
|
||||
// history into the hot store) and seed-demo (a simulated network added to a running nmon). It talks to nmon
|
||||
// only through its HTTP API and to the hot store through --hot-url, and is never shipped (docs/dev.md).
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"flag"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
var errUsage = errors.New("usage: nmon-devtools backfill|seed-demo [flags] (see docs/dev.md)")
|
||||
|
||||
func main() {
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
if err := run(ctx, os.Args[1:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "nmon-devtools:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run(ctx context.Context, args []string) error {
|
||||
if len(args) == 0 {
|
||||
return errUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "backfill":
|
||||
return backfillCmd(ctx, args[1:])
|
||||
case "seed-demo":
|
||||
return seedDemoCmd(ctx, args[1:])
|
||||
default:
|
||||
return fmt.Errorf("unknown command %q: %w", args[0], errUsage)
|
||||
}
|
||||
}
|
||||
|
||||
// flagErr turns flag.ErrHelp into a clean exit.
|
||||
func flagErr(err error) error {
|
||||
if errors.Is(err, flag.ErrHelp) {
|
||||
os.Exit(0)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// stringList is a repeatable string flag.
|
||||
type stringList []string
|
||||
|
||||
func (s *stringList) String() string { return strings.Join(*s, ",") }
|
||||
|
||||
func (s *stringList) Set(v string) error {
|
||||
for _, p := range strings.Split(v, ",") {
|
||||
if p = strings.TrimSpace(p); p != "" {
|
||||
*s = append(*s, p)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// devFlagSet is a FlagSet that prints its own usage to stderr.
|
||||
func devFlagSet(name string) *flag.FlagSet {
|
||||
fs := flag.NewFlagSet("nmon-devtools "+name, flag.ContinueOnError)
|
||||
fs.SetOutput(os.Stderr)
|
||||
return fs
|
||||
}
|
||||
|
||||
// httpJSON sends an optional JSON body and decodes a 2xx JSON reply into out (nil to discard it).
|
||||
func httpJSON(ctx context.Context, hc *http.Client, method, url string, in, out any) error {
|
||||
var body io.Reader
|
||||
if in != nil {
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
body = strings.NewReader(string(b))
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, method, url, body)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if in != nil {
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
}
|
||||
resp, err := hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }() // body read to the end below
|
||||
data, err := io.ReadAll(io.LimitReader(resp.Body, 64<<20))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("%s %s: %s: %s", method, url, resp.Status, strings.TrimSpace(string(data[:min(len(data), 300)])))
|
||||
}
|
||||
if out == nil {
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(data, out)
|
||||
}
|
||||
|
||||
func sleepCtx(ctx context.Context, d time.Duration) bool {
|
||||
t := time.NewTimer(d)
|
||||
defer t.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-t.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
69
cmd/nmon-devtools/pattern.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"math"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Synthetic traffic shapes for `nmon devtools backfill`. Everything here is a
|
||||
// pure function of its inputs, so a back-fill is reproducible and testable.
|
||||
|
||||
const (
|
||||
patternDiurnal = "diurnal"
|
||||
patternFlat = "flat"
|
||||
)
|
||||
|
||||
// splitmix64 is a tiny deterministic hash used for noise; math/rand would make
|
||||
// two runs differ and a global source would be shared state.
|
||||
func splitmix64(x uint64) uint64 {
|
||||
x += 0x9e3779b97f4a7c15
|
||||
x = (x ^ (x >> 30)) * 0xbf58476d1ce4e5b9
|
||||
x = (x ^ (x >> 27)) * 0x94d049bb133111eb
|
||||
return x ^ (x >> 31)
|
||||
}
|
||||
|
||||
// unit maps a hash to [0, 1).
|
||||
func unit(h uint64) float64 { return float64(h>>11) / (1 << 53) }
|
||||
|
||||
// rateFactor is the multiple of the mean rate at t: 1 for "flat"; for
|
||||
// "diurnal" an evening peak (about 20:00 UTC), an early-morning trough, quieter
|
||||
// weekends, per-sample noise and the odd short burst.
|
||||
func rateFactor(pattern string, t time.Time, salt uint64) float64 {
|
||||
if pattern == patternFlat {
|
||||
return 1
|
||||
}
|
||||
h := float64(t.Hour()) + float64(t.Minute())/60
|
||||
f := 1 + 0.6*math.Cos(2*math.Pi*(h-20)/24)
|
||||
if wd := t.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
f *= 0.8
|
||||
}
|
||||
slot := uint64(t.Unix() / 60)
|
||||
f *= 0.88 + 0.24*unit(splitmix64(salt^slot))
|
||||
if unit(splitmix64(salt^(slot/4)^0xb5ad4eceda1ce2a9)) < 0.01 { // ~1% of 4-minute slots burst
|
||||
f *= 2.5
|
||||
}
|
||||
return math.Max(f, 0.05)
|
||||
}
|
||||
|
||||
// counterSeries returns n cumulative octet-counter values, one per step from
|
||||
// start, beginning at 0 and never decreasing. mean is bytes per second.
|
||||
func counterSeries(pattern string, start time.Time, n int, step time.Duration, mean float64, salt uint64) []float64 {
|
||||
out := make([]float64, n)
|
||||
var total float64
|
||||
for i := 1; i < n; i++ {
|
||||
t := start.Add(time.Duration(i) * step)
|
||||
total += mean * rateFactor(pattern, t, salt) * step.Seconds()
|
||||
out[i] = math.Floor(total)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// defaultMeanBytesPerSec is a stable per-interface mean (2 to 60 Mbit/s) for when
|
||||
// the live rate is unknown.
|
||||
func defaultMeanBytesPerSec(iface string, salt uint64) float64 {
|
||||
h := salt
|
||||
for _, c := range []byte(iface) {
|
||||
h = splitmix64(h ^ uint64(c))
|
||||
}
|
||||
return (2 + 58*unit(h)*unit(h>>7)) * 125_000
|
||||
}
|
||||
56
cmd/nmon-devtools/pattern_test.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestCounterSeriesIsMonotonicAndDeterministic(t *testing.T) {
|
||||
start := time.Date(2026, 9, 1, 0, 0, 0, 0, time.UTC)
|
||||
a := counterSeries(patternDiurnal, start, 3*24*60, time.Minute, 1_000_000, 7)
|
||||
b := counterSeries(patternDiurnal, start, 3*24*60, time.Minute, 1_000_000, 7)
|
||||
for i := range a {
|
||||
if a[i] != b[i] {
|
||||
t.Fatalf("not deterministic at %d", i)
|
||||
}
|
||||
if i > 0 && a[i] < a[i-1] {
|
||||
t.Fatalf("decreased at %d", i)
|
||||
}
|
||||
}
|
||||
if a[0] != 0 {
|
||||
t.Fatalf("starts at %v, want 0", a[0])
|
||||
}
|
||||
meanRate := a[len(a)-1] / (float64(len(a)-1) * 60)
|
||||
if meanRate < 0.8e6 || meanRate > 1.4e6 {
|
||||
t.Fatalf("mean rate %.0f B/s drifted from 1e6", meanRate)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiurnalEveningBusierThanNight(t *testing.T) {
|
||||
// Monday, so the weekend factor does not interfere. Averaged over many days the noise washes out.
|
||||
var night, evening float64
|
||||
for d := 0; d < 28; d++ {
|
||||
day := time.Date(2026, 9, 7, 0, 0, 0, 0, time.UTC).AddDate(0, 0, d)
|
||||
if wd := day.Weekday(); wd == time.Saturday || wd == time.Sunday {
|
||||
continue
|
||||
}
|
||||
night += rateFactor(patternDiurnal, day.Add(5*time.Hour), 1)
|
||||
evening += rateFactor(patternDiurnal, day.Add(20*time.Hour), 1)
|
||||
}
|
||||
if evening < 2*night {
|
||||
t.Fatalf("evening %.2f is not clearly busier than night %.2f", evening, night)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFlatPatternIsConstant(t *testing.T) {
|
||||
if f := rateFactor(patternFlat, time.Now(), 3); f != 1 {
|
||||
t.Fatalf("flat factor %v", f)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultMeanIsStableAndBounded(t *testing.T) {
|
||||
m := defaultMeanBytesPerSec("01abc", 1)
|
||||
if m != defaultMeanBytesPerSec("01abc", 1) || m < 2*125_000 || m > 60*125_000 {
|
||||
t.Fatalf("mean %v", m)
|
||||
}
|
||||
}
|
||||
242
cmd/nmon-devtools/seed.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/profile"
|
||||
"github.com/v10networks/nmon/internal/simagent"
|
||||
)
|
||||
|
||||
// seedConfig is the command line of `devtools seed-demo`.
|
||||
type seedConfig struct {
|
||||
api, hotURL string
|
||||
days int
|
||||
step time.Duration
|
||||
countersRec string
|
||||
envOut string
|
||||
profilesDir string
|
||||
workers int
|
||||
hc *http.Client
|
||||
}
|
||||
|
||||
// seeded is one device after it was added to nmon.
|
||||
type seeded struct {
|
||||
demoDevice
|
||||
slug string
|
||||
agent *simagent.Agent
|
||||
ip string
|
||||
ifaces []seedIface
|
||||
}
|
||||
|
||||
type seedIface struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"ifname"`
|
||||
HC bool `json:"has_hc_counters"`
|
||||
Excluded bool `json:"excluded"`
|
||||
Unreliable bool `json:"unreliable"`
|
||||
}
|
||||
|
||||
func (i seedIface) graphable() bool { return i.HC && !i.Excluded && !i.Unreliable }
|
||||
|
||||
func seedDemoCmd(ctx context.Context, args []string) error {
|
||||
cfg := seedConfig{hc: &http.Client{Timeout: 3 * time.Minute}}
|
||||
fs := devFlagSet("seed-demo")
|
||||
fs.StringVar(&cfg.api, "api", "http://127.0.0.1:8443", "base URL of the running nmon (started with --insecure-http)")
|
||||
fs.StringVar(&cfg.hotURL, "hot-url", "", "base URL of the hot VictoriaMetrics, for the back-fill (required)")
|
||||
fs.IntVar(&cfg.days, "days", 31, "days of history to back-fill")
|
||||
fs.DurationVar(&cfg.step, "step", 60*time.Second, "back-fill sample spacing")
|
||||
fs.StringVar(&cfg.countersRec, "counters-rec", "testdata/snmprec/counters.snmprec", "the counters fixture recording")
|
||||
fs.StringVar(&cfg.envOut, "env-out", "", "write NMON_DEVICE=<counters slug> and NMON_DEVICE_48=<48-port slug> to this file when ready")
|
||||
fs.StringVar(&cfg.profilesDir, "profiles-dir", "", "operator profiles directory (shipped profiles are always loaded)")
|
||||
fs.IntVar(&cfg.workers, "workers", 6, "parallel back-fill workers")
|
||||
if err := fs.Parse(args); err != nil {
|
||||
return flagErr(err)
|
||||
}
|
||||
if cfg.hotURL == "" {
|
||||
return errors.New("--hot-url is required")
|
||||
}
|
||||
cfg.api, cfg.hotURL = strings.TrimRight(cfg.api, "/"), strings.TrimRight(cfg.hotURL, "/")
|
||||
return cfg.run(ctx)
|
||||
}
|
||||
|
||||
func (c *seedConfig) run(ctx context.Context) error {
|
||||
set, err := profile.Load(c.profilesDir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fleet, err := buildFleet(set, c.countersRec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var pool simagent.Pool
|
||||
defer func() { _ = pool.Close() }() // process is exiting
|
||||
added, err := c.addAll(ctx, &pool, fleet)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.backfillAll(ctx, added); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := c.writeEnv(added); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Println("seed-demo ready: agents keep answering until interrupted")
|
||||
<-ctx.Done()
|
||||
return nil
|
||||
}
|
||||
|
||||
// addAll starts one agent per device on [::1] with its own port and adds the
|
||||
// device through the API with that port.
|
||||
func (c *seedConfig) addAll(ctx context.Context, pool *simagent.Pool, fleet []demoDevice) ([]seeded, error) {
|
||||
var out []seeded
|
||||
for i, d := range fleet {
|
||||
cfg := simagent.Config{Addr: "[::1]:0", Recording: d.rec, Community: d.community}
|
||||
if err := pool.Start(ctx, []simagent.Config{cfg}); err != nil {
|
||||
return nil, fmt.Errorf("%s: %w", d.name, err)
|
||||
}
|
||||
agent := pool.Agents()[i]
|
||||
s, err := c.addDevice(ctx, d, "::1", int(agent.Addr().Port()))
|
||||
s.agent = agent
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("add %s: %w", d.name, err)
|
||||
}
|
||||
fmt.Printf("added %-12s %-10s %s (%d interfaces)\n", d.name, d.kind, s.slug, len(s.ifaces))
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *seedConfig) addDevice(ctx context.Context, d demoDevice, ip string, port int) (seeded, error) {
|
||||
version := d.version
|
||||
if version == "" {
|
||||
version = "v2c"
|
||||
}
|
||||
body := map[string]any{
|
||||
"name": d.name, "addresses": []string{ip}, "site": d.site, "role": d.role,
|
||||
"snmp": map[string]any{"version": version, "community": d.community, "port": port},
|
||||
}
|
||||
var created struct {
|
||||
Slug string `json:"slug"`
|
||||
}
|
||||
if err := httpJSON(ctx, c.hc, http.MethodPost, c.api+"/api/v1/devices", body, &created); err != nil {
|
||||
return seeded{}, err
|
||||
}
|
||||
s := seeded{demoDevice: d, slug: created.Slug, ip: ip}
|
||||
var got struct {
|
||||
Interfaces []seedIface `json:"interfaces"`
|
||||
}
|
||||
if err := httpJSON(ctx, c.hc, http.MethodGet, c.api+"/api/v1/devices/"+url.PathEscape(created.Slug), nil, &got); err != nil {
|
||||
return seeded{}, err
|
||||
}
|
||||
if len(got.Interfaces) == 0 {
|
||||
return seeded{}, errors.New("discovery found no interfaces")
|
||||
}
|
||||
s.ifaces = got.Interfaces
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// backfillAll waits for each device's first live samples, then writes history
|
||||
// for every graphable interface in parallel.
|
||||
func (c *seedConfig) backfillAll(ctx context.Context, devs []seeded) error {
|
||||
type job struct{ dev, iface string }
|
||||
var jobs []job
|
||||
probe := &backfillConfig{hotURL: c.hotURL, hc: c.hc}
|
||||
for _, d := range devs {
|
||||
for _, i := range d.ifaces {
|
||||
if d.hc && i.graphable() {
|
||||
jobs = append(jobs, job{d.slug, i.ID})
|
||||
}
|
||||
}
|
||||
if err := c.waitLive(ctx, probe, d); err != nil {
|
||||
return err
|
||||
}
|
||||
if d.kind == "down" {
|
||||
_ = d.agent.Close() // goes silent now, so it reads as down by the time the screenshots are taken
|
||||
}
|
||||
}
|
||||
ch := make(chan job)
|
||||
errs := make(chan error, len(jobs))
|
||||
var wg sync.WaitGroup
|
||||
for w := 0; w < max(1, c.workers); w++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for j := range ch {
|
||||
cfg := &backfillConfig{hotURL: c.hotURL, device: j.dev, days: c.days, step: c.step, pattern: patternDiurnal,
|
||||
seed: 1, hc: c.hc, logf: func(string, ...any) {}}
|
||||
errs <- cfg.backfillIface(ctx, j.iface)
|
||||
}
|
||||
}()
|
||||
}
|
||||
for _, j := range jobs {
|
||||
ch <- j
|
||||
}
|
||||
close(ch)
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
fmt.Printf("back-filled %d interfaces, %d days at %s\n", len(jobs), c.days, c.step)
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitLive blocks until the hot store holds two live samples of the device's
|
||||
// first graphable interface, so the back-fill can end where they begin.
|
||||
func (c *seedConfig) waitLive(ctx context.Context, probe *backfillConfig, d seeded) error {
|
||||
if !d.hc {
|
||||
return nil
|
||||
}
|
||||
var first string
|
||||
for _, i := range d.ifaces {
|
||||
if i.graphable() {
|
||||
first = i.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if first == "" {
|
||||
return nil
|
||||
}
|
||||
deadline := time.Now().Add(3 * time.Minute)
|
||||
for time.Now().Before(deadline) {
|
||||
live, err := probe.probe(ctx, "ifHCInOctets", first)
|
||||
if err == nil && live.ok && live.rate > 0 {
|
||||
return nil
|
||||
}
|
||||
if !sleepCtx(ctx, 2*time.Second) {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s: no live samples in the hot store after 3 minutes (is nmon polling? --poll-interval)", d.slug)
|
||||
}
|
||||
|
||||
func (c *seedConfig) writeEnv(devs []seeded) error {
|
||||
var b bytes.Buffer
|
||||
for _, d := range devs {
|
||||
switch d.kind {
|
||||
case "counters":
|
||||
fmt.Fprintf(&b, "NMON_DEVICE=%s\n", d.slug)
|
||||
case "big":
|
||||
fmt.Fprintf(&b, "NMON_DEVICE_48=%s\n", d.slug)
|
||||
case "v1":
|
||||
fmt.Fprintf(&b, "NMON_DEVICE_32BIT=%s\n", d.slug)
|
||||
case "down":
|
||||
fmt.Fprintf(&b, "NMON_DEVICE_DOWN=%s\n", d.slug)
|
||||
}
|
||||
}
|
||||
if c.envOut == "" {
|
||||
return nil
|
||||
}
|
||||
return os.WriteFile(c.envOut, b.Bytes(), 0o600)
|
||||
}
|
||||
84
cmd/nmon-devtools/store.go
Normal file
@@ -0,0 +1,84 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/metrics"
|
||||
)
|
||||
|
||||
// parseExport reads VictoriaMetrics' /api/v1/export (JSON lines) and returns
|
||||
// the earliest sample of the first series.
|
||||
func parseExport(raw []byte) (liveSeries, error) {
|
||||
line, _, _ := bytes.Cut(bytes.TrimSpace(raw), []byte("\n"))
|
||||
if len(line) == 0 {
|
||||
return liveSeries{}, nil
|
||||
}
|
||||
var s struct {
|
||||
Values []float64 `json:"values"`
|
||||
Timestamps []int64 `json:"timestamps"`
|
||||
}
|
||||
if err := json.Unmarshal(line, &s); err != nil {
|
||||
return liveSeries{}, fmt.Errorf("decode export: %w", err)
|
||||
}
|
||||
if len(s.Values) == 0 || len(s.Values) != len(s.Timestamps) {
|
||||
return liveSeries{}, nil
|
||||
}
|
||||
out := liveSeries{ts: time.UnixMilli(s.Timestamps[0]).UTC(), val: s.Values[0], ok: true}
|
||||
if len(s.Values) > 1 && s.Timestamps[1] > s.Timestamps[0] {
|
||||
out.rate = (s.Values[1] - s.Values[0]) / (float64(s.Timestamps[1]-s.Timestamps[0]) / 1000)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (c *backfillConfig) get(ctx context.Context, u string, dst io.Writer) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, u, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }() // read to the end below
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
b, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
|
||||
return fmt.Errorf("GET %s: %s: %s", u, resp.Status, b)
|
||||
}
|
||||
_, err = io.Copy(dst, resp.Body)
|
||||
return err
|
||||
}
|
||||
|
||||
// importBatch is how many samples go in one POST (about 5 MB of text).
|
||||
const importBatch = 50_000
|
||||
|
||||
// send writes samples through /api/v1/import/prometheus in batches.
|
||||
func (c *backfillConfig) send(ctx context.Context, samples []metrics.Sample) error {
|
||||
for len(samples) > 0 {
|
||||
n := min(len(samples), importBatch)
|
||||
body := make([]byte, 0, n*110)
|
||||
for _, s := range samples[:n] {
|
||||
body = metrics.AppendPromText(body, s)
|
||||
}
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.hotURL+"/api/v1/import/prometheus", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp, err := c.hc.Do(req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
msg, _ := io.ReadAll(io.LimitReader(resp.Body, 300))
|
||||
_ = resp.Body.Close() // reply already read
|
||||
if resp.StatusCode/100 != 2 {
|
||||
return fmt.Errorf("import: %s: %s", resp.Status, msg)
|
||||
}
|
||||
samples = samples[n:]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
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
|
||||
|
||||
25
cmd/nmon/openapi.go
Normal file
@@ -0,0 +1,25 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/v10networks/nmon/internal/app"
|
||||
)
|
||||
|
||||
func init() {
|
||||
register("openapi", "print the generated OpenAPI 3.1 document (nmon openapi > openapi.json)", openapiCmd)
|
||||
}
|
||||
|
||||
func openapiCmd(_ context.Context, args []string) error {
|
||||
if len(args) > 0 {
|
||||
return fmt.Errorf("openapi takes no arguments, got %q", args[0])
|
||||
}
|
||||
spec, err := app.OpenAPISpec()
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate spec: %w", err)
|
||||
}
|
||||
_, err = fmt.Fprintf(os.Stdout, "%s\n", spec)
|
||||
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
|
||||
|
||||
@@ -14,3 +14,10 @@ conscious, reviewed exception.
|
||||
| 7 | `import-librenms` reads `includes/definitions/*.yaml` and `includes/definitions/discovery/*.yaml` | Reads `resources/definitions/os_detection/*.yaml` and `resources/definitions/os_discovery/*.yaml`, with the old paths as a fallback. | LibreNMS moved them; the spec's paths no longer exist upstream. | M2 |
|
||||
| 8 | Real recordings replace synthetic fixtures when available | LibreNMS's `tests/snmpsim/*.snmprec` (2,054 real device recordings) are used by optional tests when `NMON_LIBRENMS_DIR` points at a checkout. They are never copied into this repo. | Real-device coverage without vendoring GPL test data into an Apache-2.0 repo. | M2 |
|
||||
| 9 | `nmon profile import-librenms <repo-path>` is an nmon subcommand | A separate binary, `nmon-librenms-import` (`cmd/nmon-librenms-import`), run offline before nmon. It writes draft profiles that the operator reviews and copies into `<data-dir>/profiles/`. `nmon` neither links nor exposes it. | User decision (2026-10-22). It keeps the importer out of the product binary and makes "the converter never runs at serve time" structural. | M2 |
|
||||
| 10 | `scripts/openapi-diff` fails on an incompatible change (M3 milestone) | The differ is the Go package `internal/openapidiff` (unit-tested, run with `go run ./internal/openapidiff/cmd/openapidiff`), driven by `scripts/openapi-check.sh`, which `make gate-m3` and the CI `openapi` job call. | A Go package is testable and linted like the rest of the code; the script only fetches the spec at the last `m*`/`v*` tag. No new dependency. | M3 |
|
||||
| 11 | Built assets are embedded from `web/dist` into `internal/webui` | `make web` copies `web/dist` to `internal/webui/dist` (git-ignored, `.gitkeep` tracked); a committed `internal/webui/placeholder/index.html` ("UI not built") is served when `dist` has no `index.html`. | A tracked `dist/index.html` would be overwritten by every build and dirty the tree, and a built UI must never be committed. `go build` still works without node. | M3 |
|
||||
| 12 | (not specified) | Developer tools (`backfill`, `seed-demo`) are a separate executable, `cmd/nmon-devtools` (`make devtools` builds `nmon-devtools`), never shipped. It talks to a running nmon over its HTTP API and to the hot store via `--hot-url`. `nmon` has no `devtools` command; a depguard rule keeps `cmd/nmon` from importing `internal/simagent` or `internal/fixturegen` beyond `profile_record.go`, which already used them for `nmon profile record` in M2. | The gate needs 30 days of history without waiting 30 days, and live screenshots need a simulated network. See `docs/dev.md`. | M3 |
|
||||
| 13 | (not specified) `/docs` API reference | `/docs` serves a vendored, pinned swagger-ui-dist 5.17.14 (Apache-2.0, about 1.6 MB) through `embed.FS`. | huma's built-in renderers load their assets from a CDN, which an offline monitor cannot rely on. | M3 |
|
||||
| 14 | M2 defined provisional `poller/health` and `identity-reviews` responses | Both were reshaped in M3 to the reviewed UI contract, before any `openapi.json` existed, so the route freeze starts at `m3`, the first tag with a checked-in spec. | The M2 shapes had no consumer. `scripts/openapi-check.sh` passes against `m2` with a note and diffs for real from `m3`. | M3 |
|
||||
| 15 | A single-signal failure (ICMP only or SNMP only) is a warning | The warning is held as pending for 5 min before it notifies, and is closed silently if the device goes down meanwhile. | Otherwise a real outage whose second signal fails seconds later sends a spurious warning first. | M4 |
|
||||
| 16 | An unsupported probe is unknown, never failed | When ICMP is unsupported (host can't open unprivileged ICMP, or per device), SNMP alone decides down, as with `icmp_blocked`, and a one-time `icmp_unsupported` device warning is raised. | Otherwise nmon could never declare a device down on such a host, and nobody would notice. | M4 |
|
||||
|
||||
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.
|
||||
50
docs/dev.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# Developer notes
|
||||
|
||||
## Building the UI into the binary
|
||||
|
||||
`make build` runs `make web` first: `npm run build` in `web/`, then `web/dist` is copied to
|
||||
`internal/webui/dist`, which `//go:embed` packs into the binary. `internal/webui/dist` is git-ignored
|
||||
(only `.gitkeep` is tracked), so a build never dirties the tree. A plain `go build` without `make web`
|
||||
embeds a one-line "UI not built" page from `internal/webui/placeholder/`.
|
||||
|
||||
nmon serves the UI at `/`. Unknown paths fall back to `index.html` for the client-side router, except
|
||||
`/api/`, `/docs`, `/openapi*`, `/metrics` and `/schemas`, which belong to the API. Files under `/assets/`
|
||||
are content-hashed and sent `immutable`; `index.html` is `no-cache`; text assets are served gzipped.
|
||||
|
||||
## OpenAPI compatibility
|
||||
|
||||
`scripts/openapi-check.sh` compares the current spec (`nmon openapi`, else `openapi.json`) with
|
||||
`openapi.json` at the latest `m*`/`v*` tag and fails on a removed route or method, a removed response
|
||||
field, a changed field type, a new required parameter or request field, or a removed enum value. A tag
|
||||
without `openapi.json` passes with a note. `OPENAPI_NEW=file.json` compares that file instead and
|
||||
`OPENAPI_BASE_TAG=m3` picks the base. The comparison is `internal/openapidiff`.
|
||||
|
||||
## `nmon-devtools` (separate dev binary)
|
||||
|
||||
Developer helpers in their own executable, `make devtools` -> `nmon-devtools`; the shipped `nmon` does not contain them and never links the simulator. They need a running `nmon serve --insecure-http`.
|
||||
|
||||
**backfill** writes synthetic history straight into the hot store so graphs have 30 days of data without
|
||||
waiting 30 days:
|
||||
|
||||
nmon-devtools backfill --hot-url http://127.0.0.1:PORT --device SLUG --iface ULID [--iface ULID...] \
|
||||
[--days 31] [--step 60s] [--pattern diurnal|flat] [--mean-mbps N]
|
||||
|
||||
Per interface it writes `ifHCInOctets`, `ifHCOutOctets` (monotonic, diurnal rate with noise and bursts) and
|
||||
`ifOperStatus`, ending one step before the first live sample. When live samples exist the history is shifted
|
||||
so its last value joins the live counter with no reset (this needs a live counter larger than the history,
|
||||
about 3e13 bytes for 31 days at 100 Mbit/s; otherwise a warning says the join will look like a reset).
|
||||
The hot URL is in the serve log (`metric stores up hot=...`). Samples older than hot retention are dropped
|
||||
by the store.
|
||||
|
||||
**seed-demo** builds a small simulated network and back-fills it, for the gate and for screenshots:
|
||||
|
||||
nmon serve --data-dir D --insecure-http --poll-interval 10s ...
|
||||
nmon-devtools seed-demo --api http://127.0.0.1:8443 --hot-url http://127.0.0.1:PORT \
|
||||
[--env-out env] [--counters-rec testdata/snmprec/counters.snmprec]
|
||||
|
||||
It starts the simulated SNMP agents in-process on `[::1]`, each on its own port (passed to the API as `snmp.port`), adds the devices through the API (a 48-port switch, access switches, routers, a firewall, a UPS, the counters fixture, a v1 device with 32-bit counters, and one whose agent is stopped after seeding so it reads as down), waits for live polls, back-fills 31 days, writes `NMON_DEVICE`, `NMON_DEVICE_48`, `NMON_DEVICE_32BIT` and `NMON_DEVICE_DOWN` to `--env-out`, and keeps the agents answering until interrupted. The simulated 64-bit counters start at 1e14 so history fits below them.
|
||||
|
||||
## Gates
|
||||
|
||||
`make gate-m3` runs everything above end to end and writes live screenshots to `docs/screenshots/M3/`.
|
||||
It needs Playwright's chromium: `cd web && npx playwright install chromium`.
|
||||
@@ -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).
|
||||
|
||||
@@ -33,4 +33,36 @@ 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)_
|
||||
**Passed 2026-10-22**, run by the supervisor (Opus).
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `make lint` (Go + web) | 0 findings |
|
||||
| `go test -race ./...`, `-tags 'integration vmembed'`, `test-pg.sh` | all ok |
|
||||
| `make gate-m1`, `make gate-m2` (with LibreNMS recordings) | PASS |
|
||||
| `make gate-m3` | PASS: UI embedded in the static binary; SPA fallback and cache headers; 10 simulated devices (`nmon-devtools seed-demo`) with 31 days of back-filled history; port graph renders in and out at 1 h, 24 h, 30 d; 48-port page < 2 s; openapi-check; web client regenerated from the live spec type-checks |
|
||||
| UI ↔ API contract | `TestGeneratedSpecMatchesUIContract`: the generated `openapi.json` is compatible with the contract the UI was built against |
|
||||
| Screenshots | `docs/screenshots/M3/` (real data, 18 files) and `docs/screenshots/M3-mock/` (mock data); reviewed by the supervisor and sent to the owner |
|
||||
|
||||
**Decisions in M3:** dev tooling is a separate `nmon-devtools` binary (owner's call), never shipped. `/docs` uses vendored Swagger UI. The two M2 provisional routes were reshaped before the route freeze, which starts at `m3`, the first tag with `openapi.json`. Graph stitching joins at now − hot retention; p95 is computed live until M5 materialises it.
|
||||
|
||||
**Carried forward:** a demo device that is genuinely Down needs an unreachable address; that comes with M4's reachability work (`device-unresponsive.png` shows Warning/degraded for now, which is correct per policy because ICMP still answers). `nmon profile record` still links the simulator package; to be moved to a shared `internal/snmprec` format package.
|
||||
|
||||
## 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) | **passed**, tagged `m3` |
|
||||
| M4 | Alerting | stopping a parent fixture sends one notification naming it and its children | [M4](M4-alerting.md) | in progress (WP1 reach and deps, WP2 rules, 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 |
BIN
docs/screenshots/M3/device-32bit-warning.png
Normal file
|
After Width: | Height: | Size: 91 KiB |
BIN
docs/screenshots/M3/device-48-graphs-full.png
Normal file
|
After Width: | Height: | Size: 568 KiB |
BIN
docs/screenshots/M3/device-48-graphs.png
Normal file
|
After Width: | Height: | Size: 152 KiB |
BIN
docs/screenshots/M3/device-light.png
Normal file
|
After Width: | Height: | Size: 116 KiB |
BIN
docs/screenshots/M3/device-unresponsive.png
Normal file
|
After Width: | Height: | Size: 154 KiB |
BIN
docs/screenshots/M3/device.png
Normal file
|
After Width: | Height: | Size: 117 KiB |
BIN
docs/screenshots/M3/devices-light.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/screenshots/M3/devices.png
Normal file
|
After Width: | Height: | Size: 82 KiB |
BIN
docs/screenshots/M3/graph-1h.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
docs/screenshots/M3/graph-24h-light.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/screenshots/M3/graph-24h.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
docs/screenshots/M3/graph-30d.png
Normal file
|
After Width: | Height: | Size: 153 KiB |
BIN
docs/screenshots/M3/graph-unreliable.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
docs/screenshots/M3/interfaces.png
Normal file
|
After Width: | Height: | Size: 105 KiB |
BIN
docs/screenshots/M3/phone-device.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
docs/screenshots/M3/phone-devices.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
docs/screenshots/M3/phone-graph-24h.png
Normal file
|
After Width: | Height: | Size: 50 KiB |
BIN
docs/screenshots/M3/poller.png
Normal file
|
After Width: | Height: | Size: 72 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)
|
||||
}
|
||||
99
internal/api/admit.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// admission keeps metric-store query load bounded: a per-client cap on
|
||||
// free-form queries (so one dashboard cannot starve the rest) and a global
|
||||
// semaphore on every store query (so the UI cannot crowd out the poll path).
|
||||
// A request over its client cap is refused at once; one waiting for a global
|
||||
// slot waits up to wait, then is refused. Both are 429 problems.
|
||||
type admission struct {
|
||||
global chan struct{}
|
||||
perClient int
|
||||
wait time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
active map[string]int
|
||||
}
|
||||
|
||||
func newAdmission(perClient, global int, wait time.Duration) *admission {
|
||||
return &admission{global: make(chan struct{}, global), perClient: perClient, wait: wait, active: map[string]int{}}
|
||||
}
|
||||
|
||||
// clientKey carries the caller's identity from the middleware to the handlers.
|
||||
// Until authentication exists (M6) it is the remote address without the port.
|
||||
type clientKey struct{}
|
||||
|
||||
func clientKeyMiddleware(ctx huma.Context, next func(huma.Context)) {
|
||||
host, _, err := net.SplitHostPort(ctx.RemoteAddr())
|
||||
if err != nil {
|
||||
host = ctx.RemoteAddr()
|
||||
}
|
||||
next(huma.WithValue(ctx, clientKey{}, host))
|
||||
}
|
||||
|
||||
func clientOf(ctx context.Context) string {
|
||||
c, _ := ctx.Value(clientKey{}).(string)
|
||||
return c
|
||||
}
|
||||
|
||||
func tooMany(msg string) error {
|
||||
return huma.Error429TooManyRequests(msg)
|
||||
}
|
||||
|
||||
// acquire admits one query. freeForm queries also count against the client's cap.
|
||||
// The returned release must be called exactly once.
|
||||
func (a *admission) acquire(ctx context.Context, freeForm bool) (release func(), err error) {
|
||||
client := clientOf(ctx)
|
||||
if freeForm && !a.takeClient(client) {
|
||||
return nil, tooMany("too many concurrent queries from " + client + "; the limit is " + itoa(a.perClient))
|
||||
}
|
||||
wctx, cancel := context.WithTimeout(ctx, a.wait)
|
||||
defer cancel()
|
||||
select {
|
||||
case a.global <- struct{}{}:
|
||||
case <-wctx.Done():
|
||||
if freeForm {
|
||||
a.dropClient(client)
|
||||
}
|
||||
return nil, tooMany("the metric store is busy; try again shortly")
|
||||
}
|
||||
return func() {
|
||||
<-a.global
|
||||
if freeForm {
|
||||
a.dropClient(client)
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *admission) takeClient(client string) bool {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.active[client] >= a.perClient {
|
||||
return false
|
||||
}
|
||||
a.active[client]++
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *admission) dropClient(client string) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
if a.active[client] <= 1 {
|
||||
delete(a.active, client)
|
||||
return
|
||||
}
|
||||
a.active[client]--
|
||||
}
|
||||
|
||||
// admit takes a query slot for the request in ctx.
|
||||
func (s *server) admit(ctx context.Context, freeForm bool) (func(), error) {
|
||||
return s.adm.acquire(ctx, freeForm)
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"cmp"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
@@ -16,6 +17,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"
|
||||
)
|
||||
@@ -30,6 +33,8 @@ const (
|
||||
MaxSeries = 100_000
|
||||
// DiscoveryTimeout bounds the seed discovery run by POST /devices.
|
||||
DiscoveryTimeout = 30 * time.Second
|
||||
// Version is the API's OpenAPI info.version; it changes only with the route contract, never with a build.
|
||||
Version = "1.0.0"
|
||||
)
|
||||
|
||||
// Querier runs read-only MetricsQL against the hot store. *vmsup.Querier implements it.
|
||||
@@ -50,17 +55,43 @@ 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
|
||||
|
||||
// Reachability decides up/down/warning per device. Default: what is persisted now
|
||||
// (poll state, last_seen, the ICMP and SNMP up series). M4 swaps in the alerter's state.
|
||||
Reachability ReachabilitySource
|
||||
// PollInterval is the normal poll cadence; a device unheard of for three of them is down. Default 60 s.
|
||||
PollInterval time.Duration
|
||||
|
||||
// HotRetention, FiveMinuteRetention and LongRetention are the stores' retention periods. They decide where a
|
||||
// graph stitches and what GET /meta reports. Defaults: 30 d, 120 d, 2 y.
|
||||
HotRetention, FiveMinuteRetention, LongRetention time.Duration
|
||||
// WriteQueueCapacity is the hot write queue's size in samples, for GET /poller/health. Default 2,000,000.
|
||||
WriteQueueCapacity int
|
||||
|
||||
// MaxClientQueries and MaxGlobalQueries bound concurrent free-form queries per client and metric-store
|
||||
// queries overall (defaults 4 and 16). AdmissionWait is how long a request waits for a global slot (default 5 s).
|
||||
MaxClientQueries, MaxGlobalQueries int
|
||||
AdmissionWait time.Duration
|
||||
}
|
||||
|
||||
type server struct {
|
||||
Deps
|
||||
adm *admission
|
||||
rate *counterRates
|
||||
}
|
||||
|
||||
// New returns the API handler with every route registered.
|
||||
func New(d Deps) http.Handler {
|
||||
// withDefaults fills every unset optional dependency.
|
||||
func (d Deps) withDefaults() Deps {
|
||||
if d.Clock == nil {
|
||||
d.Clock = clock.Real()
|
||||
}
|
||||
@@ -70,24 +101,72 @@ func New(d Deps) http.Handler {
|
||||
if d.Logger == nil {
|
||||
d.Logger = slog.Default()
|
||||
}
|
||||
if d.DiscoveryTimeout <= 0 {
|
||||
d.DiscoveryTimeout = DiscoveryTimeout
|
||||
d.DiscoveryTimeout = cmp.Or(d.DiscoveryTimeout, DiscoveryTimeout)
|
||||
d.PollInterval = cmp.Or(d.PollInterval, time.Minute)
|
||||
d.HotRetention = cmp.Or(d.HotRetention, 30*day)
|
||||
d.FiveMinuteRetention = cmp.Or(d.FiveMinuteRetention, 120*day)
|
||||
d.LongRetention = cmp.Or(d.LongRetention, 2*365*day)
|
||||
d.WriteQueueCapacity = cmp.Or(d.WriteQueueCapacity, 2_000_000)
|
||||
d.MaxClientQueries = cmp.Or(d.MaxClientQueries, 4)
|
||||
d.MaxGlobalQueries = cmp.Or(d.MaxGlobalQueries, 16)
|
||||
d.AdmissionWait = cmp.Or(d.AdmissionWait, 5*time.Second)
|
||||
if d.Reachability == nil {
|
||||
d.Reachability = &persistedReachability{q: d.Querier, clk: d.Clock, interval: d.PollInterval}
|
||||
}
|
||||
s := &server{d}
|
||||
return d
|
||||
}
|
||||
|
||||
const day = 24 * time.Hour
|
||||
|
||||
// New returns the API handler with every route registered.
|
||||
func New(d Deps) http.Handler {
|
||||
d = d.withDefaults()
|
||||
s := &server{Deps: d, adm: newAdmission(d.MaxClientQueries, d.MaxGlobalQueries, d.AdmissionWait), rate: &counterRates{}}
|
||||
s.rate.perMinute(s.readCounters()) // baseline, so the first poller/health call has a window to measure
|
||||
mux := http.NewServeMux()
|
||||
cfg := huma.DefaultConfig("nmon", "1.0.0")
|
||||
api := s.newHuma(mux)
|
||||
registerDocs(mux)
|
||||
s.register(api)
|
||||
return problemFallback(mux)
|
||||
}
|
||||
|
||||
// Spec returns the OpenAPI 3.1 document of the API as indented JSON. It is the
|
||||
// same document served at /openapi.json, generated without any dependencies:
|
||||
// `nmon openapi > openapi.json` checks it in.
|
||||
func Spec() ([]byte, error) {
|
||||
s := &server{Deps: Deps{}.withDefaults()}
|
||||
api := s.newHuma(http.NewServeMux())
|
||||
s.register(api)
|
||||
return json.MarshalIndent(api.OpenAPI(), "", " ")
|
||||
}
|
||||
|
||||
// register adds every operation to api.
|
||||
func (s *server) register(api huma.API) {
|
||||
s.registerDevices(api)
|
||||
s.registerDeviceList(api)
|
||||
s.registerDeviceViews(api)
|
||||
s.registerInterfaces(api)
|
||||
s.registerGraph(api)
|
||||
s.registerOps(api)
|
||||
s.registerHealth(api)
|
||||
s.registerReviews(api)
|
||||
s.registerQuery(api)
|
||||
s.registerMeta(api)
|
||||
s.registerMetrics(api)
|
||||
}
|
||||
|
||||
// newHuma builds the huma API on mux. /docs is served by registerDocs from embedded assets, not huma's CDN page.
|
||||
func (s *server) newHuma(mux *http.ServeMux) huma.API {
|
||||
configureHuma()
|
||||
cfg := huma.DefaultConfig("nmon", Version)
|
||||
cfg.Info.Description = "nmon network monitor API."
|
||||
cfg.OpenAPIPath = "/openapi"
|
||||
cfg.DocsPath = "/docs"
|
||||
cfg.DocsRenderer = huma.DocsRendererSwaggerUI
|
||||
cfg.DocsPath = ""
|
||||
cfg.CreateHooks = nil // no $schema fields or Link headers in responses
|
||||
cfg.OnAddOperation = append(cfg.OnAddOperation, addDefaultProblem)
|
||||
api := humago.New(mux, cfg)
|
||||
|
||||
s.registerDevices(api)
|
||||
s.registerQuery(api)
|
||||
s.registerMetrics(api)
|
||||
return problemFallback(mux)
|
||||
api.UseMiddleware(clientKeyMiddleware)
|
||||
return api
|
||||
}
|
||||
|
||||
// storeErr maps store sentinels to problem responses; anything else is a 500
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -16,6 +17,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"
|
||||
@@ -24,25 +26,57 @@ import (
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
)
|
||||
|
||||
// querier is the fake metric store. fn, when set, answers every query;
|
||||
// otherwise body does. Every query is recorded.
|
||||
type querier struct {
|
||||
mu sync.Mutex
|
||||
body []byte
|
||||
err error
|
||||
fn func(store, expr string, from, to time.Time, step time.Duration) ([]byte, error)
|
||||
q string
|
||||
ts, from, to time.Time
|
||||
step time.Duration
|
||||
instant, rangeCnt int
|
||||
calls []call
|
||||
}
|
||||
|
||||
type call struct {
|
||||
store, expr string
|
||||
from, to time.Time
|
||||
step time.Duration
|
||||
}
|
||||
|
||||
func (q *querier) Query(_ context.Context, st, expr string, ts time.Time) ([]byte, error) {
|
||||
q.mu.Lock()
|
||||
q.instant++
|
||||
q.q, q.ts = expr, ts
|
||||
return q.body, q.err
|
||||
q.calls = append(q.calls, call{store: st, expr: expr, from: ts, to: ts})
|
||||
fn, body, err := q.fn, q.body, q.err
|
||||
q.mu.Unlock()
|
||||
if fn != nil {
|
||||
return fn(st, expr, ts, ts, 0)
|
||||
}
|
||||
return body, err
|
||||
}
|
||||
|
||||
func (q *querier) QueryRange(_ context.Context, st, expr string, from, to time.Time, step time.Duration) ([]byte, error) {
|
||||
q.mu.Lock()
|
||||
q.rangeCnt++
|
||||
q.q, q.from, q.to, q.step = expr, from, to, step
|
||||
return q.body, q.err
|
||||
q.calls = append(q.calls, call{store: st, expr: expr, from: from, to: to, step: step})
|
||||
fn, body, err := q.fn, q.body, q.err
|
||||
q.mu.Unlock()
|
||||
if fn != nil {
|
||||
return fn(st, expr, from, to, step)
|
||||
}
|
||||
return body, err
|
||||
}
|
||||
|
||||
// queries returns a copy of the recorded queries.
|
||||
func (q *querier) queries() []call {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return append([]call(nil), q.calls...)
|
||||
}
|
||||
|
||||
type refresher struct{ n atomic.Int32 }
|
||||
@@ -50,19 +84,32 @@ type refresher struct{ n atomic.Int32 }
|
||||
func (r *refresher) Refresh() { r.n.Add(1) }
|
||||
|
||||
type env struct {
|
||||
clk *clock.Fake
|
||||
h http.Handler
|
||||
st store.Store
|
||||
q *querier
|
||||
ref *refresher
|
||||
reg *selfmon.Registry
|
||||
|
||||
afterID atomic.Int64
|
||||
status []poller.DeviceStatus
|
||||
}
|
||||
|
||||
func newEnv(t *testing.T, port int) *env {
|
||||
func newEnv(t *testing.T, port int) *env { return newEnvDeps(t, port, nil) }
|
||||
|
||||
// newEnvDeps is newEnv with a chance to change the API's dependencies.
|
||||
func newEnvDeps(t *testing.T, port int, mutate func(*api.Deps)) *env {
|
||||
clk := clock.NewFake(time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC))
|
||||
e := &env{st: testutil.OpenStore(t, clk), q: &querier{body: []byte(`{"status":"success","data":{"resultType":"vector","result":[]}}`)},
|
||||
e := &env{clk: clk, 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})
|
||||
d := api.Deps{Store: e.st, Querier: e.q, Poller: e.ref, Clock: clk, Registry: e.reg, SNMPPort: port,
|
||||
DiscoveryTimeout: 3 * time.Second,
|
||||
PollerStatus: func() []poller.DeviceStatus { return e.status },
|
||||
AfterDiscover: func(id int64) { e.afterID.Store(id) }}
|
||||
if mutate != nil {
|
||||
mutate(&d)
|
||||
}
|
||||
e.h = api.New(d)
|
||||
return e
|
||||
}
|
||||
|
||||
@@ -270,3 +317,24 @@ func TestQueryProxy(t *testing.T) {
|
||||
e.q.err = errors.New("connection reset")
|
||||
isProblem(t, e.do("GET", "/api/v1/query?q=x", ""), 502)
|
||||
}
|
||||
|
||||
func TestDocsAreSelfHosted(t *testing.T) {
|
||||
e := newEnv(t, 0)
|
||||
page := e.do("GET", "/docs", "")
|
||||
if page.Code != 200 || strings.Contains(page.Body.String(), "http") && strings.Contains(page.Body.String(), "unpkg") ||
|
||||
!strings.Contains(page.Body.String(), "/docs/assets/swagger-ui-bundle.js") {
|
||||
t.Fatalf("/docs: %d %s", page.Code, page.Body)
|
||||
}
|
||||
for path, ct := range map[string]string{"/docs/assets/swagger-ui-bundle.js": "javascript", "/docs/assets/swagger-ui.css": "css", "/docs/assets/init.js": "javascript"} {
|
||||
rec := e.do("GET", path, "")
|
||||
if rec.Code != 200 || rec.Body.Len() < 50 || !strings.Contains(rec.Header().Get("Content-Type"), ct) {
|
||||
t.Errorf("%s: %d %s %d bytes", path, rec.Code, rec.Header().Get("Content-Type"), rec.Body.Len())
|
||||
}
|
||||
}
|
||||
if !strings.Contains(e.do("GET", "/docs/assets/init.js", "").Body.String(), "/openapi.json") {
|
||||
t.Error("the page must load the spec from this server")
|
||||
}
|
||||
if rec := e.do("GET", "/openapi.json", ""); rec.Code != 200 || !strings.Contains(rec.Body.String(), `"openapi":"3.1.0"`) {
|
||||
t.Errorf("openapi.json: %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
435
internal/api/contract_test.go
Normal file
@@ -0,0 +1,435 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"slices"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
)
|
||||
|
||||
// The UI is built against web/openapi.contract.json. These tests hold the
|
||||
// generated OpenAPI document to it: every path, method, parameter, request
|
||||
// body and response field of the contract must exist in the generated spec
|
||||
// with a compatible type. Additions in the generated spec are fine.
|
||||
|
||||
type obj = map[string]any
|
||||
|
||||
// knownDivergences are places where the generated spec deliberately differs
|
||||
// from the hand-written contract. Each has a reason; anything else is a failure.
|
||||
var knownDivergences = map[string]string{
|
||||
// M1/M2 shipped these two as strings that also accept Unix seconds and "30s"; the routes are frozen once shipped
|
||||
// and the UI does not call them.
|
||||
"param GET /api/v1/query_range step: type": "frozen since M1: accepts 30s and plain seconds",
|
||||
"param GET /api/v1/query_range from: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
|
||||
"param GET /api/v1/query_range to: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
|
||||
"param GET /api/v1/query time: format": "frozen since M1: accepts RFC 3339 and Unix seconds",
|
||||
// The contract left q optional; an empty expression is meaningless, so the API requires it.
|
||||
"param GET /api/v1/query q: required": "q is required by the frozen M1 route",
|
||||
"param GET /api/v1/query_range q: required": "q is required by the frozen M1 route",
|
||||
"param GET /api/v1/query_range step: required": "step is required by the frozen M1 route",
|
||||
}
|
||||
|
||||
type checker struct {
|
||||
t *testing.T
|
||||
gen obj
|
||||
con obj
|
||||
failed []string
|
||||
}
|
||||
|
||||
func (c *checker) fail(key, format string, a ...any) {
|
||||
if _, ok := knownDivergences[key]; ok {
|
||||
return
|
||||
}
|
||||
c.failed = append(c.failed, fmt.Sprintf("%s: %s", key, fmt.Sprintf(format, a...)))
|
||||
}
|
||||
|
||||
func load(t *testing.T) *checker {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile("../../web/openapi.contract.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var con obj
|
||||
if err := json.Unmarshal(raw, &con); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
spec, err := api.Spec()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var gen obj
|
||||
if err := json.Unmarshal(spec, &gen); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &checker{t: t, gen: gen, con: con}
|
||||
}
|
||||
|
||||
func get(m any, keys ...string) any {
|
||||
for _, k := range keys {
|
||||
mm, ok := m.(obj)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
m = mm[k]
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// resolve follows $ref within doc.
|
||||
func resolve(doc obj, s any) obj {
|
||||
m, _ := s.(obj)
|
||||
for m != nil {
|
||||
ref, ok := m["$ref"].(string)
|
||||
if !ok {
|
||||
return m
|
||||
}
|
||||
m, _ = get(doc, strings.Split(strings.TrimPrefix(ref, "#/"), "/")...).(obj)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// shape is a schema reduced to what the comparison needs.
|
||||
type shape struct {
|
||||
typ string
|
||||
nullable bool
|
||||
s obj
|
||||
}
|
||||
|
||||
func shapeOf(doc obj, raw any) shape {
|
||||
s := resolve(doc, raw)
|
||||
sh := shape{s: s}
|
||||
switch t := s["type"].(type) {
|
||||
case string:
|
||||
sh.typ = t
|
||||
case []any:
|
||||
for _, x := range t {
|
||||
if x == "null" {
|
||||
sh.nullable = true
|
||||
} else {
|
||||
sh.typ, _ = x.(string)
|
||||
}
|
||||
}
|
||||
}
|
||||
if alts, ok := s["anyOf"].([]any); ok {
|
||||
for _, a := range alts {
|
||||
am := resolve(doc, a)
|
||||
if am["type"] == "null" {
|
||||
sh.nullable = true
|
||||
continue
|
||||
}
|
||||
sub := shapeOf(doc, am)
|
||||
sh.typ, sh.s = sub.typ, sub.s
|
||||
}
|
||||
}
|
||||
return sh
|
||||
}
|
||||
|
||||
// compat checks that gen can stand where con is expected. request flips the
|
||||
// direction of the required/nullable rules: a client sends what the contract
|
||||
// allows, and receives what the contract promises.
|
||||
func (c *checker) compat(key string, con, gen any, request bool) {
|
||||
cs, gs := shapeOf(c.con, con), shapeOf(c.gen, gen)
|
||||
if cs.typ != gs.typ && (cs.typ != "number" || gs.typ != "integer") {
|
||||
c.fail(key+": type", "contract %q, generated %q", cs.typ, gs.typ)
|
||||
return
|
||||
}
|
||||
if !request && cs.nullable && !gs.nullable {
|
||||
c.fail(key+": nullable", "contract allows null, generated never sends it (fine) -- but UI types expect null; declare nullable")
|
||||
}
|
||||
if !request && gs.nullable && !cs.nullable {
|
||||
c.fail(key+": nullable", "generated may send null, contract does not allow it")
|
||||
}
|
||||
for _, k := range []string{"format", "minimum", "maximum", "minLength", "maxLength", "minItems", "maxItems"} {
|
||||
if cv, ok := cs.s[k]; ok && fmt.Sprint(cv) != fmt.Sprint(gs.s[k]) {
|
||||
c.fail(key+": "+k, "contract %v, generated %v", cv, gs.s[k])
|
||||
}
|
||||
}
|
||||
c.enums(key, cs.s, gs.s, request)
|
||||
switch cs.typ {
|
||||
case "object":
|
||||
c.object(key, cs.s, gs.s, request)
|
||||
case "array":
|
||||
c.array(key, cs.s, gs.s, request)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) enums(key string, con, gen obj, request bool) {
|
||||
ce, _ := con["enum"].([]any)
|
||||
if len(ce) == 0 {
|
||||
return
|
||||
}
|
||||
ge, _ := gen["enum"].([]any)
|
||||
have := func(list []any, v any) bool { return slices.Contains(list, v) }
|
||||
for _, v := range ge {
|
||||
if !request && !have(ce, v) {
|
||||
c.fail(key+": enum", "generated may send %v, not in the contract", v)
|
||||
}
|
||||
}
|
||||
for _, v := range ce {
|
||||
if (request || len(ge) == 0) && !have(ge, v) {
|
||||
c.fail(key+": enum", "contract value %v missing from generated", v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) object(key string, con, gen obj, request bool) {
|
||||
cp, _ := con["properties"].(obj)
|
||||
gp, _ := gen["properties"].(obj)
|
||||
names := make([]string, 0, len(cp))
|
||||
for n := range cp {
|
||||
names = append(names, n)
|
||||
}
|
||||
sort.Strings(names)
|
||||
for _, n := range names {
|
||||
if _, ok := gp[n]; !ok {
|
||||
c.fail(key+"."+n, "missing from generated")
|
||||
continue
|
||||
}
|
||||
c.compat(key+"."+n, cp[n], gp[n], request)
|
||||
}
|
||||
creq, greq := strset(con["required"]), strset(gen["required"])
|
||||
if request {
|
||||
for n := range greq {
|
||||
if !creq[n] {
|
||||
c.fail(key+"."+n+": required", "generated requires a field the contract made optional")
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
for n := range creq {
|
||||
if !greq[n] {
|
||||
c.fail(key+"."+n+": required", "contract promises it, generated may omit it")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func strset(v any) map[string]bool {
|
||||
out := map[string]bool{}
|
||||
l, _ := v.([]any)
|
||||
for _, x := range l {
|
||||
out[fmt.Sprint(x)] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (c *checker) array(key string, con, gen obj, request bool) {
|
||||
if pc, ok := con["prefixItems"].([]any); ok {
|
||||
pg, _ := gen["prefixItems"].([]any)
|
||||
if len(pg) != len(pc) {
|
||||
c.fail(key+": prefixItems", "contract has %d, generated %d", len(pc), len(pg))
|
||||
return
|
||||
}
|
||||
for i := range pc {
|
||||
c.compat(fmt.Sprintf("%s[%d]", key, i), pc[i], pg[i], request)
|
||||
}
|
||||
return
|
||||
}
|
||||
if con["items"] != nil {
|
||||
if gen["items"] == nil {
|
||||
c.fail(key+": items", "generated array has no items schema")
|
||||
return
|
||||
}
|
||||
c.compat(key+"[]", con["items"], gen["items"], request)
|
||||
}
|
||||
}
|
||||
|
||||
// operations lists "METHOD path" for every operation of doc, with path-level parameters merged in.
|
||||
func operations(doc obj) map[string]obj {
|
||||
out := map[string]obj{}
|
||||
paths, _ := doc["paths"].(obj)
|
||||
for p, item := range paths {
|
||||
im, _ := item.(obj)
|
||||
for m, op := range im {
|
||||
om, ok := op.(obj)
|
||||
if !ok || m == "parameters" {
|
||||
continue
|
||||
}
|
||||
merged := obj{}
|
||||
for k, v := range om {
|
||||
merged[k] = v
|
||||
}
|
||||
params, _ := im["parameters"].([]any)
|
||||
merged["parameters"] = append(slices.Clone(params), asList(om["parameters"])...)
|
||||
out[strings.ToUpper(m)+" "+p] = merged
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func asList(v any) []any { l, _ := v.([]any); return l }
|
||||
|
||||
func (c *checker) params(opKey string, con, gen obj) {
|
||||
genByName := map[string]obj{}
|
||||
for _, p := range asList(gen["parameters"]) {
|
||||
pm := resolve(c.gen, p)
|
||||
genByName[fmt.Sprint(pm["in"])+" "+fmt.Sprint(pm["name"])] = pm
|
||||
}
|
||||
conByName := map[string]bool{}
|
||||
for _, p := range asList(con["parameters"]) {
|
||||
pm := resolve(c.con, p)
|
||||
id := fmt.Sprint(pm["in"]) + " " + fmt.Sprint(pm["name"])
|
||||
conByName[id] = true
|
||||
key := fmt.Sprintf("param %s %s", opKey, pm["name"])
|
||||
g, ok := genByName[id]
|
||||
if !ok {
|
||||
c.fail(key, "missing from generated")
|
||||
continue
|
||||
}
|
||||
if pm["required"] == true && g["required"] != true {
|
||||
c.fail(key+": required", "contract requires it, generated does not")
|
||||
}
|
||||
if pm["required"] != true && g["required"] == true {
|
||||
c.fail(key+": required", "generated requires a parameter the contract made optional")
|
||||
}
|
||||
c.compat(key, pm["schema"], g["schema"], true)
|
||||
}
|
||||
for id, g := range genByName {
|
||||
if g["required"] == true && !conByName[id] {
|
||||
c.fail(fmt.Sprintf("param %s %s", opKey, g["name"]), "new required parameter not in the contract")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *checker) body(opKey string, con, gen obj) {
|
||||
cb := resolve(c.con, get(con, "requestBody"))
|
||||
if cb == nil {
|
||||
return
|
||||
}
|
||||
gb := resolve(c.gen, get(gen, "requestBody"))
|
||||
cs, gs := get(cb, "content", "application/json", "schema"), get(gb, "content", "application/json", "schema")
|
||||
if gs == nil {
|
||||
c.fail("body "+opKey, "generated has no JSON request body")
|
||||
return
|
||||
}
|
||||
c.compat("body "+opKey, cs, gs, true)
|
||||
}
|
||||
|
||||
func (c *checker) responses(opKey string, con, gen obj) {
|
||||
cr, _ := con["responses"].(obj)
|
||||
gr, _ := gen["responses"].(obj)
|
||||
for code, r := range cr {
|
||||
g, ok := gr[code]
|
||||
if !ok {
|
||||
c.fail("response "+opKey+" "+code, "missing from generated")
|
||||
continue
|
||||
}
|
||||
cm, _ := get(resolve(c.con, r), "content").(obj)
|
||||
for ct, media := range cm {
|
||||
gm := get(resolve(c.gen, g), "content", ct, "schema")
|
||||
if gm == nil {
|
||||
c.fail("response "+opKey+" "+code, "no %s content in generated", ct)
|
||||
continue
|
||||
}
|
||||
c.compat(fmt.Sprintf("response %s %s", opKey, code), get(media, "schema"), gm, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeneratedSpecMatchesUIContract(t *testing.T) {
|
||||
c := load(t)
|
||||
if c.gen["openapi"] != "3.1.0" {
|
||||
t.Fatalf("generated openapi version %v, want 3.1.0", c.gen["openapi"])
|
||||
}
|
||||
conOps, genOps := operations(c.con), operations(c.gen)
|
||||
for key, con := range conOps {
|
||||
gen, ok := genOps[key]
|
||||
if !ok {
|
||||
c.fail("operation "+key, "missing from generated spec")
|
||||
continue
|
||||
}
|
||||
c.params(key, con, gen)
|
||||
c.body(key, con, gen)
|
||||
c.responses(key, con, gen)
|
||||
}
|
||||
if len(c.failed) > 0 {
|
||||
sort.Strings(c.failed)
|
||||
t.Fatalf("generated spec breaks the UI contract (%d):\n %s", len(c.failed), strings.Join(c.failed, "\n "))
|
||||
}
|
||||
}
|
||||
|
||||
// TestContractDivergencesStayNeeded fails when a divergence is no longer needed, so the list shrinks.
|
||||
func TestContractDivergencesStayNeeded(t *testing.T) {
|
||||
c := load(t)
|
||||
saved := knownDivergences
|
||||
defer func() { knownDivergences = saved }()
|
||||
knownDivergences = map[string]string{}
|
||||
conOps, genOps := operations(c.con), operations(c.gen)
|
||||
for key, con := range conOps {
|
||||
if gen, ok := genOps[key]; ok {
|
||||
c.params(key, con, gen)
|
||||
}
|
||||
}
|
||||
hit := strings.Join(c.failed, "\n")
|
||||
for k := range saved {
|
||||
if !strings.Contains(hit, k) {
|
||||
t.Errorf("divergence %q no longer happens; remove it", k)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestResponseSchemasExposeNoIDsOrCredentials walks every schema reachable from a
|
||||
// response in the generated spec: no integer id, and no credential-like property.
|
||||
func TestResponseSchemasExposeNoIDsOrCredentials(t *testing.T) {
|
||||
c := load(t)
|
||||
seen := map[string]bool{}
|
||||
var walk func(path string, raw any)
|
||||
walk = func(path string, raw any) {
|
||||
m, _ := raw.(obj)
|
||||
if ref, ok := m["$ref"].(string); ok {
|
||||
if seen[ref] {
|
||||
return
|
||||
}
|
||||
seen[ref] = true
|
||||
walk(path+"→"+ref, resolve(c.gen, m))
|
||||
return
|
||||
}
|
||||
props, _ := m["properties"].(obj)
|
||||
for name, p := range props {
|
||||
lower := strings.ToLower(name)
|
||||
if (lower == "id" || strings.HasSuffix(lower, "_id")) && shapeOf(c.gen, p).typ != "string" {
|
||||
t.Errorf("%s.%s is not a string id", path, name)
|
||||
}
|
||||
for _, bad := range []string{"community", "password", "pass", "hash", "secret", "token", "credential", "key"} {
|
||||
if strings.Contains(lower, bad) {
|
||||
t.Errorf("%s.%s looks like a credential", path, name)
|
||||
}
|
||||
}
|
||||
walk(path+"."+name, p)
|
||||
}
|
||||
if items := m["items"]; items != nil {
|
||||
walk(path+"[]", items)
|
||||
}
|
||||
for _, k := range []string{"anyOf", "oneOf", "allOf"} {
|
||||
for _, a := range asList(m[k]) {
|
||||
walk(path, a)
|
||||
}
|
||||
}
|
||||
}
|
||||
for key, op := range operations(c.gen) {
|
||||
responses, _ := op["responses"].(obj)
|
||||
for code, r := range responses {
|
||||
walk(key+" "+code, get(resolve(c.gen, r), "content", "application/json", "schema"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestCheckedInOpenAPIIsCurrent keeps openapi.json (the file the UI client is
|
||||
// generated from) equal to what the handlers produce.
|
||||
func TestCheckedInOpenAPIIsCurrent(t *testing.T) {
|
||||
want, err := api.Spec()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got, err := os.ReadFile("../../openapi.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.TrimSpace(string(got)) != strings.TrimSpace(string(want)) {
|
||||
t.Fatal("openapi.json is stale: run `go run ./cmd/nmon openapi > openapi.json`")
|
||||
}
|
||||
}
|
||||
226
internal/api/detail_test.go
Normal file
@@ -0,0 +1,226 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
func TestDeviceDetailLiveRates(t *testing.T) {
|
||||
e := newEnv(t, 0)
|
||||
core := e.device(t, "Core", "yyz", "core", func(d *store.Device) { d.HWSerial, d.SysDescr = "SN1", "JunOS" })
|
||||
other := e.device(t, "Other", "", "")
|
||||
i1 := e.iface(t, core, 1, "xe-0/0/1", "up")
|
||||
i2 := e.iface(t, core, 2, "xe-0/0/2", "32bit", func(i *store.Interface) { i.HasHCCounters = false })
|
||||
i3 := e.iface(t, core, 3, "xe-0/0/3", "bad", func(i *store.Interface) { i.Unreliable, i.HasHCCounters = true, false })
|
||||
i4 := e.iface(t, core, 4, "xe-0/0/4", "idle")
|
||||
i5 := e.iface(t, core, 5, "xe-0/0/5", "gone")
|
||||
if err := e.st.RetireInterface(t.Context(), i5.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := e.st.SetParents(t.Context(), core.ID, store.RelNetwork, []int64{other.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
e.q.fn = func(_, expr string, _, _ time.Time, _ time.Duration) ([]byte, error) {
|
||||
if !strings.Contains(expr, `device="`+core.Slug+`"`) {
|
||||
return emptyMatrix, nil
|
||||
}
|
||||
l := func(id, k string) map[string]string { return map[string]string{"iface": id, "k": k} }
|
||||
return vector(
|
||||
vrow{l(i1.ID, "in_hc"), 8e6}, vrow{l(i1.ID, "out_hc"), 4e6}, vrow{l(i1.ID, "in_err"), 0.5}, vrow{l(i1.ID, "out_err"), 0},
|
||||
vrow{l(i1.ID, "in_32"), 999}, // a stale 32-bit series must not win over the 64-bit counter
|
||||
vrow{l(i2.ID, "in_32"), 100}, vrow{l(i2.ID, "out_32"), 200},
|
||||
vrow{l(i3.ID, "in_32"), 5}, vrow{l(i3.ID, "out_32"), 5}, vrow{l(i3.ID, "in_err"), 1},
|
||||
vrow{map[string]string{"k": "uptime"}, 86400},
|
||||
), nil
|
||||
}
|
||||
var d api.DeviceView
|
||||
rec := e.getJSON(t, "/api/v1/devices/"+core.Slug, &d)
|
||||
assertNoLeaks(t, "device detail", rec.Body.Bytes())
|
||||
var rateQueries []string
|
||||
for _, c := range e.q.queries() {
|
||||
if strings.Contains(c.expr, "ifHCInOctets") {
|
||||
rateQueries = append(rateQueries, c.expr)
|
||||
}
|
||||
}
|
||||
if len(rateQueries) != 1 || !strings.Contains(rateQueries[0], "rate(ifHCInOctets{device=") || !strings.Contains(rateQueries[0], "[5m])*8") {
|
||||
t.Errorf("want one batched rate query, got %q", rateQueries)
|
||||
}
|
||||
if d.Slug != core.Slug || d.HWSerial != "SN1" || d.SysDescr != "JunOS" || d.UptimeSeconds == nil || *d.UptimeSeconds != 86400 ||
|
||||
len(d.Parents) != 1 || d.Parents[0].Slug != other.Slug || d.Parents[0].Name != "Other" || d.Groups == nil ||
|
||||
d.Reachability == "" || d.PollState != "ok" || len(d.Interfaces) != 5 {
|
||||
t.Fatalf("detail: %+v", d)
|
||||
}
|
||||
by := map[string]api.InterfaceView{}
|
||||
for _, i := range d.Interfaces {
|
||||
by[i.ID] = i
|
||||
}
|
||||
if x := by[i1.ID]; x.InBPS == nil || *x.InBPS != 8e6 || *x.OutBPS != 4e6 || *x.InErrorsPS != 0.5 || x.OutErrorsPS == nil {
|
||||
t.Errorf("hc interface: %+v", x)
|
||||
}
|
||||
if x := by[i2.ID]; x.InBPS == nil || *x.InBPS != 100 || *x.OutBPS != 200 || x.InErrorsPS != nil {
|
||||
t.Errorf("32-bit interface: %+v", x)
|
||||
}
|
||||
if x := by[i3.ID]; !x.Unreliable || x.InBPS != nil || x.OutBPS != nil || x.InErrorsPS != nil {
|
||||
t.Errorf("unreliable interface must have null rates: %+v", x)
|
||||
}
|
||||
if x := by[i4.ID]; x.InBPS != nil || x.OutBPS != nil {
|
||||
t.Errorf("unknown rate must be null: %+v", x)
|
||||
}
|
||||
if x := by[i5.ID]; x.RetiredAt == nil {
|
||||
t.Errorf("retired interface should carry retired_at: %+v", x)
|
||||
}
|
||||
// every nullable field is present as null, not omitted
|
||||
var raw map[string]any
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &raw)
|
||||
ifs := raw["interfaces"].([]any)[3].(map[string]any)
|
||||
for _, k := range []string{"in_bps", "out_bps", "in_errors_ps", "out_errors_ps", "retired_at"} {
|
||||
if v, ok := ifs[k]; !ok || v != nil {
|
||||
t.Errorf("%s should be present and null: %v %v", k, v, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeviceDetailWithStoreDownStillServes(t *testing.T) {
|
||||
e := newEnv(t, 0)
|
||||
d := e.device(t, "Core", "", "")
|
||||
e.iface(t, d, 1, "e1", "")
|
||||
e.q.err = context.DeadlineExceeded
|
||||
var v api.DeviceView
|
||||
e.getJSON(t, "/api/v1/devices/"+d.Slug, &v)
|
||||
if len(v.Interfaces) != 1 || v.Interfaces[0].InBPS != nil || v.UptimeSeconds != nil {
|
||||
t.Errorf("rates should be null when the store fails: %+v", v)
|
||||
}
|
||||
isProblem(t, e.do("GET", "/api/v1/devices/nope", ""), 404)
|
||||
}
|
||||
|
||||
func TestPutDeviceEditsAndAudits(t *testing.T) {
|
||||
e := newEnv(t, 0)
|
||||
a := e.device(t, "A", "yyz", "core")
|
||||
b := e.device(t, "B", "yyz", "edge")
|
||||
var v api.DeviceView
|
||||
rec := e.do("PUT", "/api/v1/devices/"+b.Slug,
|
||||
`{"name":"B2","site":"yul","role":"dist","enabled":false,"parents":["`+a.Slug+`"]}`)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("PUT: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &v)
|
||||
if v.Name != "B2" || v.Site != "yul" || v.Role != "dist" || v.Enabled || len(v.Parents) != 1 || v.Parents[0].Slug != a.Slug {
|
||||
t.Errorf("edited: %+v", v)
|
||||
}
|
||||
if v.Slug != b.Slug {
|
||||
t.Error("slug is immutable")
|
||||
}
|
||||
if e.ref.n.Load() != 1 {
|
||||
t.Error("poller not refreshed")
|
||||
}
|
||||
// a partial body leaves the rest alone; an empty parents list clears them
|
||||
rec = e.do("PUT", "/api/v1/devices/"+b.Slug, `{"parents":[]}`)
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &v)
|
||||
if rec.Code != 200 || v.Name != "B2" || v.Site != "yul" || len(v.Parents) != 0 {
|
||||
t.Errorf("partial edit: %d %+v", rec.Code, v)
|
||||
}
|
||||
audit, _, _ := e.st.ListAudit(t.Context(), store.Page{})
|
||||
var edits int
|
||||
for _, a := range audit {
|
||||
if a.Action == "device.edit" && a.Object == b.Slug {
|
||||
edits++
|
||||
}
|
||||
}
|
||||
if edits != 2 {
|
||||
t.Errorf("audit has %d device.edit rows, want 2: %+v", edits, audit)
|
||||
}
|
||||
for body, status := range map[string]int{
|
||||
`{"name":""}`: 422, `{"name":" "}`: 422, `{"parents":["nope"]}`: 422, `{"enabled":"yes"}`: 422, `{"bogus":1}`: 422,
|
||||
`{"snmp":{"version":"v2c"}}`: 422,
|
||||
} {
|
||||
isProblem(t, e.do("PUT", "/api/v1/devices/"+b.Slug, body), status)
|
||||
}
|
||||
isProblem(t, e.do("PUT", "/api/v1/devices/nope", `{}`), 404)
|
||||
if err := e.st.RetireDevice(t.Context(), a.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
isProblem(t, e.do("PUT", "/api/v1/devices/"+a.Slug, `{"name":"x"}`), 409)
|
||||
isProblem(t, e.do("PUT", "/api/v1/devices/"+b.Slug, `{"parents":["`+a.Slug+`"]}`), 422) // retired parent
|
||||
}
|
||||
|
||||
func TestPutDeviceRejectsParentCyclesNamingThePath(t *testing.T) {
|
||||
e := newEnv(t, 0)
|
||||
a := e.device(t, "A", "", "")
|
||||
b := e.device(t, "B", "", "")
|
||||
c := e.device(t, "C", "", "")
|
||||
set := func(d *store.Device, parents ...*store.Device) {
|
||||
ids := make([]int64, len(parents))
|
||||
for i, p := range parents {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
if err := e.st.SetParents(t.Context(), d.ID, store.RelNetwork, ids); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
set(c, b) // C depends on B
|
||||
set(b, a) // B depends on A
|
||||
rec := e.do("PUT", "/api/v1/devices/"+a.Slug, `{"parents":["`+c.Slug+`"]}`)
|
||||
isProblem(t, rec, 422)
|
||||
want := a.Slug + " -> " + c.Slug + " -> " + b.Slug + " -> " + a.Slug
|
||||
if !strings.Contains(rec.Body.String(), want) {
|
||||
t.Errorf("cycle path missing; want %q in %s", want, rec.Body)
|
||||
}
|
||||
rec = e.do("PUT", "/api/v1/devices/"+a.Slug, `{"parents":["`+a.Slug+`"]}`)
|
||||
isProblem(t, rec, 422)
|
||||
if !strings.Contains(rec.Body.String(), a.Slug+" -> "+a.Slug) {
|
||||
t.Errorf("self parent: %s", rec.Body)
|
||||
}
|
||||
if got, _ := e.st.ListParents(t.Context(), a.ID, ""); len(got) != 0 {
|
||||
t.Errorf("a rejected edit must not change parents: %+v", got)
|
||||
}
|
||||
// a diamond is not a cycle
|
||||
d := e.device(t, "D", "", "")
|
||||
if rec := e.do("PUT", "/api/v1/devices/"+d.Slug, `{"parents":["`+b.Slug+`","`+c.Slug+`"]}`); rec.Code != 200 {
|
||||
t.Errorf("diamond: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPutDeviceReplacesSNMPCredentialWithoutLeakingIt(t *testing.T) {
|
||||
e := newEnv(t, 0)
|
||||
cred := &store.SNMPCredential{Name: "old", Version: "v2c", Community: "old-community-xyz"}
|
||||
if err := e.st.CreateCredential(t.Context(), cred); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
d := e.device(t, "A", "", "", func(d *store.Device) { d.SNMPCredentialID = &cred.ID })
|
||||
rec := e.do("PUT", "/api/v1/devices/"+d.Slug,
|
||||
`{"snmp":{"version":"v3","v3_user":"nmon","v3_auth_proto":"SHA256","v3_auth_pass":"auth-secret-123","v3_priv_proto":"AES","v3_priv_pass":"priv-secret-456","port":1161}}`)
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("PUT: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
assertNoLeaks(t, "PUT response", rec.Body.Bytes(), "auth-secret-123", "priv-secret-456", "old-community-xyz")
|
||||
var v api.DeviceView
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &v)
|
||||
if v.SNMPVersion != "v3" {
|
||||
t.Errorf("snmp_version %q", v.SNMPVersion)
|
||||
}
|
||||
got, err := e.st.GetDeviceBySlug(t.Context(), d.Slug)
|
||||
if err != nil || got.SNMPPort != 1161 || *got.SNMPCredentialID == cred.ID {
|
||||
t.Fatalf("device after: %+v %v", got, err)
|
||||
}
|
||||
c, err := e.st.GetCredential(t.Context(), *got.SNMPCredentialID)
|
||||
if err != nil || c.V3AuthPass != "auth-secret-123" || c.V3User != "nmon" {
|
||||
t.Errorf("stored credential: %+v %v", c, err)
|
||||
}
|
||||
if _, err := e.st.GetCredential(t.Context(), cred.ID); err == nil {
|
||||
t.Error("the replaced credential should be deleted")
|
||||
}
|
||||
audit, _, _ := e.st.ListAudit(t.Context(), store.Page{})
|
||||
for _, a := range audit {
|
||||
if strings.Contains(a.After+a.Before, "secret") {
|
||||
t.Errorf("audit row leaks a secret: %+v", a)
|
||||
}
|
||||
}
|
||||
vm := e.device(t, "VM", "", "", func(d *store.Device) { d.Kind = store.KindVM })
|
||||
isProblem(t, e.do("PUT", "/api/v1/devices/"+vm.Slug, `{"snmp":{"version":"v2c","community":"c"}}`), 422)
|
||||
}
|
||||
144
internal/api/device_put.go
Normal file
@@ -0,0 +1,144 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// PutDeviceBody is the body of PUT /api/v1/devices/{slug}. Absent fields stay
|
||||
// as they are; an empty parents list removes every parent.
|
||||
type PutDeviceBody struct {
|
||||
Name *string `json:"name,omitempty" minLength:"1" maxLength:"255"`
|
||||
Site *string `json:"site,omitempty"`
|
||||
Role *string `json:"role,omitempty"`
|
||||
Enabled *bool `json:"enabled,omitempty"`
|
||||
Parents []string `json:"parents,omitempty" doc:"Parent device slugs, replacing the current parents."`
|
||||
SNMP *SNMPBody `json:"snmp,omitempty" doc:"Replaces the SNMP credential; secrets are write-only."`
|
||||
}
|
||||
|
||||
func isNotFound(err error) bool { return errors.Is(err, store.ErrNotFound) }
|
||||
|
||||
func (s *server) putDevice(ctx context.Context, in *struct {
|
||||
Slug string `path:"slug" doc:"Device slug."`
|
||||
Body PutDeviceBody
|
||||
}) (*deviceOut, 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 {
|
||||
return nil, huma.Error409Conflict("device is retired; reattach it before editing")
|
||||
}
|
||||
plan, err := s.planEdit(ctx, dev, in.Body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.applyEdit(ctx, dev, plan); err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
s.Poller.Refresh()
|
||||
return s.view(ctx, dev.Slug)
|
||||
}
|
||||
|
||||
// editPlan is a validated edit: the device as it will be, the parents to set
|
||||
// (nil leaves them) and the credential to create (nil leaves it).
|
||||
type editPlan struct {
|
||||
after store.Device
|
||||
setParents bool
|
||||
parents []*store.Device
|
||||
cred *store.SNMPCredential
|
||||
change map[string]any // what the audit row records; never a secret
|
||||
}
|
||||
|
||||
// planEdit validates body against the device without writing anything.
|
||||
func (s *server) planEdit(ctx context.Context, dev *store.Device, b PutDeviceBody) (*editPlan, error) {
|
||||
p := &editPlan{after: *dev, change: map[string]any{}}
|
||||
set := func(field string, dst *string, v *string) {
|
||||
if v != nil && *v != *dst {
|
||||
*dst = *v
|
||||
p.change[field] = *v
|
||||
}
|
||||
}
|
||||
set("name", &p.after.Name, b.Name)
|
||||
set("site", &p.after.Site, b.Site)
|
||||
set("role", &p.after.Role, b.Role)
|
||||
if b.Name != nil && strings.TrimSpace(*b.Name) == "" {
|
||||
return nil, huma.Error422UnprocessableEntity("name must not be empty")
|
||||
}
|
||||
if b.Enabled != nil && *b.Enabled != dev.Enabled {
|
||||
p.after.Enabled = *b.Enabled
|
||||
p.change["enabled"] = *b.Enabled
|
||||
}
|
||||
if b.Parents != nil {
|
||||
parents, err := s.resolveParents(ctx, dev, b.Parents)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.setParents, p.parents = true, parents
|
||||
p.change["parents"] = b.Parents
|
||||
}
|
||||
if b.SNMP != nil {
|
||||
if dev.Kind != store.KindNetwork {
|
||||
return nil, huma.Error422UnprocessableEntity("snmp applies to network devices only")
|
||||
}
|
||||
cred, err := s.credential(b.SNMP)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.cred = cred
|
||||
p.after.SNMPPort = b.SNMP.Port
|
||||
p.change["snmp"] = map[string]any{"version": b.SNMP.Version, "port": b.SNMP.Port, "replaced": true}
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// applyEdit writes the plan and its audit row in one transaction. The old
|
||||
// credential is deleted once nothing uses it.
|
||||
func (s *server) applyEdit(ctx context.Context, dev *store.Device, p *editPlan) error {
|
||||
return s.Store.WithTx(ctx, func(tx store.Store) error {
|
||||
after := p.after
|
||||
oldCred := dev.SNMPCredentialID
|
||||
if p.cred != nil {
|
||||
if err := tx.CreateCredential(ctx, p.cred); err != nil {
|
||||
return err
|
||||
}
|
||||
after.SNMPCredentialID = &p.cred.ID
|
||||
}
|
||||
if err := tx.UpdateDevice(ctx, &after); err != nil {
|
||||
return err
|
||||
}
|
||||
if p.setParents {
|
||||
if err := setParentLinks(ctx, tx, &after, p.parents); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if p.cred != nil && oldCred != nil {
|
||||
if err := tx.DeleteCredential(ctx, *oldCred); err != nil && !errors.Is(err, store.ErrConflict) {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.AppendAudit(ctx, "anonymous", "device.edit", dev.Slug, nil, p.change)
|
||||
})
|
||||
}
|
||||
|
||||
// setParentLinks replaces the device's parents. VMs and containers depend on
|
||||
// their host (hosted_on); everything else on the network path.
|
||||
func setParentLinks(ctx context.Context, tx store.Store, d *store.Device, parents []*store.Device) error {
|
||||
rel := store.RelNetwork
|
||||
if d.Kind == store.KindVM || d.Kind == store.KindContainer {
|
||||
rel = store.RelHostedOn
|
||||
}
|
||||
ids := make([]int64, len(parents))
|
||||
for i, p := range parents {
|
||||
ids[i] = p.ID
|
||||
}
|
||||
return tx.SetParents(ctx, d.ID, rel, ids)
|
||||
}
|
||||
210
internal/api/device_view.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// DeviceRef names a device in another object. Devices are addressed by slug.
|
||||
type DeviceRef struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
// GroupView is a group an interface of the device belongs to.
|
||||
type GroupView struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind" enum:"site,customer,role,custom"`
|
||||
}
|
||||
|
||||
// InterfaceView is an interface as the API shows it, addressed by ULID. The
|
||||
// rate fields are five-minute rates from the hot store, null when unknown or
|
||||
// when the interface is unreliable (its counters cannot be trusted).
|
||||
type InterfaceView struct {
|
||||
ID string `json:"id" doc:"Immutable ULID."`
|
||||
IfIndex int64 `json:"ifindex" doc:"Current ifIndex; may change, the ID does not."`
|
||||
IfName string `json:"ifname"`
|
||||
IfDescr string `json:"ifdescr"`
|
||||
IfAlias string `json:"ifalias"`
|
||||
IfPhysAddr string `json:"ifphysaddr"`
|
||||
IfSpeed int64 `json:"ifspeed" doc:"bits per second"`
|
||||
IfHighSpeed int64 `json:"ifhighspeed" doc:"megabits per second"`
|
||||
IfType int64 `json:"iftype"`
|
||||
AdminStatus int64 `json:"admin_status" doc:"ifAdminStatus: 1 up, 2 down, 3 testing"`
|
||||
OperStatus int64 `json:"oper_status" doc:"ifOperStatus: 1 up, 2 down, 3 testing, 4 unknown, 5 dormant, 6 notPresent, 7 lowerLayerDown"` //nolint:lll // struct tag text is the OpenAPI doc; cannot wrap
|
||||
HasHCCounters bool `json:"has_hc_counters"`
|
||||
Excluded bool `json:"excluded"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastDiscovered time.Time `json:"last_discovered"`
|
||||
Unreliable bool `json:"unreliable" doc:"Counters cannot be trusted (32-bit counters on a fast link); no rate is reported."`
|
||||
RetiredAt *time.Time `json:"retired_at" nullable:"true"`
|
||||
InBPS *float64 `json:"in_bps" nullable:"true" doc:"Inbound bits per second over 5 minutes."`
|
||||
OutBPS *float64 `json:"out_bps" nullable:"true"`
|
||||
InErrorsPS *float64 `json:"in_errors_ps" nullable:"true" doc:"Inbound errors per second over 5 minutes."`
|
||||
OutErrorsPS *float64 `json:"out_errors_ps" nullable:"true"`
|
||||
}
|
||||
|
||||
// DeviceView is a device as the API shows it. It carries no numeric ID and no credential.
|
||||
type DeviceView struct {
|
||||
Slug string `json:"slug" doc:"Immutable public key."`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Site string `json:"site"`
|
||||
Role string `json:"role"`
|
||||
Profile string `json:"profile"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MgmtIP string `json:"mgmt_ip"`
|
||||
Addresses []string `json:"addresses"`
|
||||
SNMPVersion string `json:"snmp_version,omitempty"`
|
||||
SysName string `json:"sys_name"`
|
||||
SysObjectID string `json:"sys_object_id"`
|
||||
SysDescr string `json:"sys_descr"`
|
||||
HWSerial string `json:"hw_serial"`
|
||||
UptimeSeconds *float64 `json:"uptime_seconds" nullable:"true" doc:"sysUpTime from the hot store; null when unknown."`
|
||||
Reachability string `json:"reachability" enum:"up,down,warning,unknown,maintenance"`
|
||||
PollState string `json:"poll_state" enum:"ok,degraded,backoff"`
|
||||
Warnings []string `json:"warnings"`
|
||||
LastSeen *time.Time `json:"last_seen" nullable:"true"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty" nullable:"true"`
|
||||
Parents []DeviceRef `json:"parents"`
|
||||
Groups []GroupView `json:"groups"`
|
||||
Interfaces []InterfaceView `json:"interfaces"`
|
||||
}
|
||||
|
||||
type deviceOut struct{ Body DeviceView }
|
||||
|
||||
func (s *server) registerDeviceViews(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-device", Method: http.MethodGet, Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
|
||||
Summary: "Get a device, its interfaces with live rates, parents and groups",
|
||||
Description: "Rates are one batched query to the hot store; they are null when the store is unavailable.",
|
||||
Errors: []int{http.StatusNotFound},
|
||||
}, s.getDevice)
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "put-device", Method: http.MethodPut, Path: "/api/v1/devices/{slug}", Tags: []string{"devices"},
|
||||
Summary: "Edit a device",
|
||||
Description: "Only the fields present change. parents replaces the parent list (by slug) and is rejected with 422 " +
|
||||
"naming the path when it would make a cycle. snmp replaces the credential.",
|
||||
Errors: []int{http.StatusNotFound, http.StatusConflict, http.StatusUnprocessableEntity},
|
||||
}, s.putDevice)
|
||||
}
|
||||
|
||||
func (s *server) getDevice(ctx context.Context, in *struct {
|
||||
Slug string `path:"slug" doc:"Device slug."`
|
||||
}) (*deviceOut, error) {
|
||||
return s.view(ctx, in.Slug)
|
||||
}
|
||||
|
||||
// view loads the device and everything shown with it.
|
||||
func (s *server) view(ctx context.Context, slug string) (*deviceOut, error) {
|
||||
d, err := s.Store.GetDeviceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if d.PurgedAt != nil {
|
||||
return nil, huma.Error404NotFound("not found")
|
||||
}
|
||||
v := DeviceView{
|
||||
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, Profile: d.Profile, Enabled: d.Enabled,
|
||||
MgmtIP: d.MgmtIP, SysName: d.SysName, SysObjectID: d.SysObjectID, SysDescr: d.SysDescr, HWSerial: d.HWSerial,
|
||||
PollState: pollStateOf(d), Warnings: nonNil(d.Warnings), LastSeen: utcp(d.LastSeen), CreatedAt: d.CreatedAt.UTC(),
|
||||
RetiredAt: utcp(d.RetiredAt), Addresses: []string{}, Parents: []DeviceRef{}, Groups: []GroupView{},
|
||||
Reachability: s.Reachability.Reachability(ctx, []store.Device{*d})[d.Slug],
|
||||
}
|
||||
if err := s.fillRelations(ctx, d, &v); err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if err := s.fillInterfaces(ctx, d, &v); err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
return &deviceOut{Body: v}, nil
|
||||
}
|
||||
|
||||
// fillRelations adds addresses, the credential's version (never its secrets), parents and groups.
|
||||
func (s *server) fillRelations(ctx context.Context, d *store.Device, v *DeviceView) error {
|
||||
addrs, err := s.Store.ListAddresses(ctx, d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, a := range addrs {
|
||||
v.Addresses = append(v.Addresses, a.Addr)
|
||||
}
|
||||
if d.SNMPCredentialID != nil {
|
||||
c, err := s.Store.GetCredential(ctx, *d.SNMPCredentialID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.SNMPVersion = c.Version
|
||||
}
|
||||
links, err := s.Store.ListParents(ctx, d.ID, "")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
seen := map[int64]bool{}
|
||||
for _, l := range links {
|
||||
if seen[l.ParentID] {
|
||||
continue
|
||||
}
|
||||
seen[l.ParentID] = true
|
||||
p, err := s.Store.GetDeviceByID(ctx, l.ParentID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
v.Parents = append(v.Parents, DeviceRef{Slug: p.Slug, Name: p.Name})
|
||||
}
|
||||
groups, err := s.Store.DeviceGroups(ctx, d.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, g := range groups {
|
||||
v.Groups = append(v.Groups, GroupView{ID: itoa(int(g.ID)), Name: g.Name, Kind: g.Kind})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// fillInterfaces adds the device's interfaces, retired ones included, with live rates.
|
||||
func (s *server) fillInterfaces(ctx context.Context, d *store.Device, v *DeviceView) error {
|
||||
ifs, err := s.Store.ListInterfaces(ctx, d.ID, true)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var rates map[string]*ifRates
|
||||
v.Interfaces = make([]InterfaceView, 0, len(ifs))
|
||||
if len(ifs) > 0 || d.Kind == store.KindNetwork {
|
||||
rates, v.UptimeSeconds = s.liveRates(ctx, `device=`+quote(d.Slug), true)
|
||||
}
|
||||
for i := range ifs {
|
||||
v.Interfaces = append(v.Interfaces, interfaceView(&ifs[i], rates[ifs[i].ID]))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func interfaceView(i *store.Interface, r *ifRates) InterfaceView {
|
||||
in, out := r.bps(i)
|
||||
inE, outE := r.errs(i)
|
||||
return InterfaceView{
|
||||
ID: i.ID, IfIndex: i.IfIndex, IfName: i.IfName, IfDescr: i.IfDescr, IfAlias: i.IfAlias,
|
||||
IfPhysAddr: i.IfPhysAddr, IfSpeed: i.IfSpeed, IfHighSpeed: i.IfHighSpeed, IfType: i.IfType,
|
||||
AdminStatus: i.AdminStatus, OperStatus: i.OperStatus, HasHCCounters: i.HasHCCounters, Excluded: i.Excluded,
|
||||
FirstSeen: i.FirstSeen.UTC(), LastDiscovered: i.LastDiscovered.UTC(), Unreliable: i.Unreliable,
|
||||
RetiredAt: utcp(i.RetiredAt), InBPS: in, OutBPS: out, InErrorsPS: inE, OutErrorsPS: outE,
|
||||
}
|
||||
}
|
||||
|
||||
// unreliableReason says why an interface's counters cannot be trusted.
|
||||
func unreliableReason(i *store.Interface) string {
|
||||
switch {
|
||||
case !i.HasHCCounters:
|
||||
return "counter_32bit_fast_link"
|
||||
case i.CounterDiscontinuity > 0:
|
||||
return "counter_discontinuity"
|
||||
}
|
||||
return "counter_unreliable"
|
||||
}
|
||||
@@ -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,146 +28,72 @@ 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."`
|
||||
}
|
||||
|
||||
// InterfaceView is an interface as the API shows it, addressed by ULID.
|
||||
type InterfaceView struct {
|
||||
ID string `json:"id" doc:"Immutable ULID."`
|
||||
IfIndex int64 `json:"ifindex" doc:"Current ifIndex; may change, the ID does not."`
|
||||
IfName string `json:"ifname"`
|
||||
IfDescr string `json:"ifdescr"`
|
||||
IfAlias string `json:"ifalias"`
|
||||
IfPhysAddr string `json:"ifphysaddr"`
|
||||
IfSpeed int64 `json:"ifspeed" doc:"bits per second"`
|
||||
IfHighSpeed int64 `json:"ifhighspeed" doc:"megabits per second"`
|
||||
IfType int64 `json:"iftype"`
|
||||
AdminStatus int64 `json:"admin_status"`
|
||||
OperStatus int64 `json:"oper_status"`
|
||||
HasHCCounters bool `json:"has_hc_counters"`
|
||||
Excluded bool `json:"excluded"`
|
||||
FirstSeen time.Time `json:"first_seen"`
|
||||
LastDiscovered time.Time `json:"last_discovered"`
|
||||
// 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"`
|
||||
}
|
||||
|
||||
// DeviceView is a device as the API shows it. It carries no numeric ID and no credential.
|
||||
type DeviceView struct {
|
||||
Slug string `json:"slug" doc:"Immutable public key."`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind"`
|
||||
Site string `json:"site"`
|
||||
Role string `json:"role"`
|
||||
Profile string `json:"profile"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MgmtIP string `json:"mgmt_ip"`
|
||||
Addresses []string `json:"addresses"`
|
||||
SNMPVersion string `json:"snmp_version,omitempty"`
|
||||
SysName string `json:"sys_name"`
|
||||
SysObjectID string `json:"sys_object_id"`
|
||||
SysDescr string `json:"sys_descr"`
|
||||
PollState string `json:"poll_state"`
|
||||
Warnings []string `json:"warnings"`
|
||||
LastSeen *time.Time `json:"last_seen,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
RetiredAt *time.Time `json:"retired_at,omitempty"`
|
||||
Interfaces []InterfaceView `json:"interfaces"`
|
||||
// reattachProblem is the 409 problem with the retired devices the client may restore.
|
||||
type reattachProblem struct {
|
||||
Problem
|
||||
Candidates []reattachCandidate `json:"reattach_candidates"`
|
||||
}
|
||||
|
||||
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"},
|
||||
Summary: "Get a device and its interfaces",
|
||||
Errors: []int{404},
|
||||
}, 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 +101,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,70 +307,21 @@ 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())
|
||||
}
|
||||
return res, nil
|
||||
}
|
||||
|
||||
func (s *server) getDevice(ctx context.Context, in *struct {
|
||||
Slug string `path:"slug" doc:"Device slug."`
|
||||
}) (*deviceOut, error) {
|
||||
return s.view(ctx, in.Slug)
|
||||
}
|
||||
|
||||
func (s *server) view(ctx context.Context, slug string) (*deviceOut, error) {
|
||||
d, err := s.Store.GetDeviceBySlug(ctx, slug)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
if d.PurgedAt != nil {
|
||||
return nil, huma.Error404NotFound("not found")
|
||||
}
|
||||
v := DeviceView{
|
||||
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, Profile: d.Profile, Enabled: d.Enabled,
|
||||
MgmtIP: d.MgmtIP, SysName: d.SysName, SysObjectID: d.SysObjectID, SysDescr: d.SysDescr,
|
||||
PollState: d.PollState, Warnings: nonNil(d.Warnings), LastSeen: utcp(d.LastSeen), CreatedAt: d.CreatedAt.UTC(),
|
||||
RetiredAt: utcp(d.RetiredAt), Addresses: []string{}, Interfaces: []InterfaceView{},
|
||||
}
|
||||
addrs, err := s.Store.ListAddresses(ctx, d.ID)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
for _, a := range addrs {
|
||||
v.Addresses = append(v.Addresses, a.Addr)
|
||||
}
|
||||
if d.SNMPCredentialID != nil {
|
||||
c, err := s.Store.GetCredential(ctx, *d.SNMPCredentialID)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
v.SNMPVersion = c.Version // the secrets stay behind
|
||||
}
|
||||
ifs, err := s.Store.ListInterfaces(ctx, d.ID, false)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
for _, i := range ifs {
|
||||
v.Interfaces = append(v.Interfaces, InterfaceView{
|
||||
ID: i.ID, IfIndex: i.IfIndex, IfName: i.IfName, IfDescr: i.IfDescr, IfAlias: i.IfAlias,
|
||||
IfPhysAddr: i.IfPhysAddr, IfSpeed: i.IfSpeed, IfHighSpeed: i.IfHighSpeed, IfType: i.IfType,
|
||||
AdminStatus: i.AdminStatus, OperStatus: i.OperStatus, HasHCCounters: i.HasHCCounters, Excluded: i.Excluded,
|
||||
FirstSeen: i.FirstSeen.UTC(), LastDiscovered: i.LastDiscovered.UTC(),
|
||||
})
|
||||
}
|
||||
return &deviceOut{Body: v}, nil
|
||||
}
|
||||
|
||||
func nonNil(s []string) []string {
|
||||
if s == nil {
|
||||
return []string{}
|
||||
@@ -279,3 +336,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
|
||||
}
|
||||
|
||||
136
internal/api/devices_list.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// DeviceSummary is one row of the device list.
|
||||
type DeviceSummary struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
Kind string `json:"kind" enum:"network,vm,container,check_only"`
|
||||
Site string `json:"site"`
|
||||
Role string `json:"role"`
|
||||
MgmtIP string `json:"mgmt_ip"`
|
||||
Profile string `json:"profile"`
|
||||
Reachability string `json:"reachability" enum:"up,down,warning,unknown,maintenance"`
|
||||
PollState string `json:"poll_state" enum:"ok,degraded,backoff"`
|
||||
LastSeen *time.Time `json:"last_seen" nullable:"true"`
|
||||
InterfacesTotal int64 `json:"interfaces_total" doc:"Active interfaces, excluded ones left out."`
|
||||
InterfacesDown int64 `json:"interfaces_down" doc:"Active interfaces that are admin up and not oper up."`
|
||||
Warnings []string `json:"warnings"`
|
||||
}
|
||||
|
||||
// DeviceList is a page of DeviceSummary.
|
||||
type DeviceList struct {
|
||||
Items []DeviceSummary `json:"items"`
|
||||
Page int64 `json:"page"`
|
||||
PerPage int64 `json:"per_page"`
|
||||
Total int64 `json:"total"`
|
||||
}
|
||||
|
||||
// DeviceFacets are the distinct values the list's filter dropdowns offer.
|
||||
type DeviceFacets struct {
|
||||
Sites []string `json:"sites"`
|
||||
Roles []string `json:"roles"`
|
||||
Kinds []string `json:"kinds"`
|
||||
}
|
||||
|
||||
func (s *server) registerDeviceList(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "list-devices", Method: http.MethodGet, Path: "/api/v1/devices", Tags: []string{"devices"},
|
||||
Summary: "List devices",
|
||||
Description: "Paged, with reachability and interface tallies. state filters on the derived reachability, " +
|
||||
"so it is applied after the other filters.",
|
||||
}, s.listDevices)
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-device-facets", Method: http.MethodGet, Path: "/api/v1/devices/facets", Tags: []string{"devices"},
|
||||
Summary: "Distinct site, role and kind values of unretired devices",
|
||||
}, s.deviceFacets)
|
||||
}
|
||||
|
||||
func (s *server) listDevices(ctx context.Context, in *struct {
|
||||
Site string `query:"site"`
|
||||
Role string `query:"role"`
|
||||
Kind string `query:"kind" enum:"network,vm,container,check_only"`
|
||||
Q string `query:"q" doc:"Substring of name, slug, management address or sysName."`
|
||||
State string `query:"state" enum:"up,down,warning,unknown,maintenance" doc:"Reachability."`
|
||||
PageParams
|
||||
}) (*struct{ Body DeviceList }, error) {
|
||||
f := store.DeviceFilter{Site: in.Site, Role: in.Role, Kind: in.Kind, Q: in.Q}
|
||||
var (
|
||||
devs []store.Device
|
||||
total int64
|
||||
reach map[string]string
|
||||
err error
|
||||
)
|
||||
if in.State == "" {
|
||||
devs, total, err = s.Store.ListDevices(ctx, f, store.Page{Offset: in.offset(), Limit: in.limit()})
|
||||
if err == nil {
|
||||
reach = s.Reachability.Reachability(ctx, devs)
|
||||
}
|
||||
} else {
|
||||
devs, total, reach, err = s.devicesInState(ctx, f, in.State, in.PageParams)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
counts, err := s.Store.InterfaceCounts(ctx)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
out := DeviceList{Items: make([]DeviceSummary, 0, len(devs)), Page: in.pageNo(), PerPage: in.limit(), Total: total}
|
||||
for i := range devs {
|
||||
d := &devs[i]
|
||||
c := counts[d.ID]
|
||||
out.Items = append(out.Items, DeviceSummary{
|
||||
Slug: d.Slug, Name: d.Name, Kind: d.Kind, Site: d.Site, Role: d.Role, MgmtIP: d.MgmtIP, Profile: d.Profile,
|
||||
Reachability: reach[d.Slug], PollState: pollStateOf(d), LastSeen: utcp(d.LastSeen),
|
||||
InterfacesTotal: c.Total, InterfacesDown: c.Down, Warnings: nonNil(d.Warnings),
|
||||
})
|
||||
}
|
||||
return &struct{ Body DeviceList }{out}, nil
|
||||
}
|
||||
|
||||
// devicesInState pages over the devices whose derived reachability is state.
|
||||
// Reachability is not a column, so every device matching the other filters is
|
||||
// classified first and the page is cut from the survivors.
|
||||
func (s *server) devicesInState(
|
||||
ctx context.Context, f store.DeviceFilter, state string, p PageParams,
|
||||
) (page []store.Device, total int64, reach map[string]string, err error) {
|
||||
var all []store.Device
|
||||
for off := int64(0); ; off += store.MaxLimit {
|
||||
rows, n, err := s.Store.ListDevices(ctx, f, store.Page{Offset: off, Limit: store.MaxLimit})
|
||||
if err != nil {
|
||||
return nil, 0, nil, err
|
||||
}
|
||||
all = append(all, rows...)
|
||||
if off+store.MaxLimit >= n {
|
||||
break
|
||||
}
|
||||
}
|
||||
reach = s.Reachability.Reachability(ctx, all)
|
||||
var match []store.Device
|
||||
for _, d := range all {
|
||||
if reach[d.Slug] == state {
|
||||
match = append(match, d)
|
||||
}
|
||||
}
|
||||
lo := min(p.offset(), int64(len(match)))
|
||||
hi := min(lo+p.limit(), int64(len(match)))
|
||||
return match[lo:hi], int64(len(match)), reach, nil
|
||||
}
|
||||
|
||||
func (s *server) deviceFacets(ctx context.Context, _ *struct{}) (*struct{ Body DeviceFacets }, error) {
|
||||
sites, roles, kinds, err := s.Store.DeviceFacets(ctx)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
return &struct{ Body DeviceFacets }{DeviceFacets{Sites: nonNil(sites), Roles: nonNil(roles), Kinds: nonNil(kinds)}}, nil
|
||||
}
|
||||
31
internal/api/docs.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"net/http"
|
||||
)
|
||||
|
||||
// docsFS holds the Swagger UI page and its vendored swagger-ui-dist 5.17.14
|
||||
// assets, so /docs works on a network with no internet access (huma's own
|
||||
// renderers load their JavaScript from a CDN).
|
||||
//
|
||||
//go:embed docsui
|
||||
var docsFS embed.FS
|
||||
|
||||
// registerDocs serves Swagger UI at /docs and its assets under /docs/assets/.
|
||||
func registerDocs(mux *http.ServeMux) {
|
||||
sub, err := fs.Sub(docsFS, "docsui")
|
||||
if err != nil {
|
||||
panic("api: embedded docs missing: " + err.Error()) // the embed directive guarantees the directory
|
||||
}
|
||||
page, err := fs.ReadFile(sub, "index.html")
|
||||
if err != nil {
|
||||
panic("api: embedded docs page missing: " + err.Error())
|
||||
}
|
||||
mux.HandleFunc("GET /docs", func(w http.ResponseWriter, _ *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||
_, _ = w.Write(page)
|
||||
})
|
||||
mux.Handle("GET /docs/assets/", http.StripPrefix("/docs/assets/", http.FileServerFS(sub)))
|
||||
}
|
||||
202
internal/api/docsui/LICENSE
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
2
internal/api/docsui/NOTICE
Normal file
@@ -0,0 +1,2 @@
|
||||
swagger-ui
|
||||
Copyright 2020-2021 SmartBear Software Inc.
|
||||
14
internal/api/docsui/index.html
Normal file
@@ -0,0 +1,14 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>nmon API</title>
|
||||
<link rel="stylesheet" href="/docs/assets/swagger-ui.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="swagger-ui"></div>
|
||||
<script src="/docs/assets/swagger-ui-bundle.js"></script>
|
||||
<script src="/docs/assets/init.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
1
internal/api/docsui/init.js
Normal file
@@ -0,0 +1 @@
|
||||
window.ui = SwaggerUIBundle({ url: '/openapi.json', dom_id: '#swagger-ui', deepLinking: true });
|
||||
2
internal/api/docsui/swagger-ui-bundle.js
Normal file
3
internal/api/docsui/swagger-ui.css
Normal file
@@ -1,7 +1,9 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
@@ -17,11 +19,12 @@ func TestStoreErrorMapping(t *testing.T) {
|
||||
status int
|
||||
detail string
|
||||
}{
|
||||
{fmt.Errorf("%w: slug taken", store.ErrConflict), 409, "slug taken"},
|
||||
{fmt.Errorf("%w: bad kind", store.ErrInvalid), 422, "bad kind"},
|
||||
{fmt.Errorf("wrapped: %w", store.ErrNotFound), 404, "not found"},
|
||||
{fmt.Errorf("%w: slug taken", store.ErrConflict), http.StatusConflict, "slug taken"},
|
||||
{fmt.Errorf("%w: bad kind", store.ErrInvalid), http.StatusUnprocessableEntity, "bad kind"},
|
||||
{fmt.Errorf("wrapped: %w", store.ErrNotFound), http.StatusNotFound, "not found"},
|
||||
} {
|
||||
se, ok := s.storeErr(tc.err).(huma.StatusError)
|
||||
var se huma.StatusError
|
||||
ok := errors.As(s.storeErr(tc.err), &se)
|
||||
if !ok || se.GetStatus() != tc.status || se.Error() != tc.detail {
|
||||
t.Errorf("%v: got %v, want %d %q", tc.err, se, tc.status, tc.detail)
|
||||
}
|
||||
|
||||
142
internal/api/graph.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
func (s *server) registerGraph(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-graph", Method: http.MethodGet, Path: "/api/v1/graph", Tags: []string{"graph"},
|
||||
Summary: "Bits per second in and out of one interface",
|
||||
Description: "From the hot store when the range lies within hot retention; older data comes from the long store's " +
|
||||
"hourly rollups, and a range that needs both is stitched at stitched_at. Stretches with no data in either store " +
|
||||
"are listed in partial. An unreliable interface returns no series. max=true adds the per-step maximum; " +
|
||||
"p95=true adds the 95th percentile of 5-minute averages, computed live.",
|
||||
Errors: []int{http.StatusNotFound, http.StatusUnprocessableEntity, http.StatusTooManyRequests, http.StatusServiceUnavailable},
|
||||
}, s.graph)
|
||||
}
|
||||
|
||||
type graphIn struct {
|
||||
Iface string `query:"iface" required:"true" minLength:"1" doc:"Interface ULID."`
|
||||
From time.Time `query:"from" doc:"Start, RFC 3339. Default one hour before to."`
|
||||
To time.Time `query:"to" doc:"End, RFC 3339. Default now."`
|
||||
Step int64 `query:"step" minimum:"1" doc:"Step in seconds. Default max(60, range/1000) rounded up to whole minutes."`
|
||||
Dir string `query:"dir" enum:"in,out" doc:"Only this direction; the other series is empty."`
|
||||
Max bool `query:"max" doc:"Also return in_max and out_max, the highest one-minute rate within each step."`
|
||||
P95 bool `query:"p95" doc:"Also return the live 95th percentile of 5-minute averages."`
|
||||
}
|
||||
|
||||
func (s *server) graph(ctx context.Context, in *graphIn) (*struct{ Body GraphResponse }, error) {
|
||||
i, err := s.Store.GetInterface(ctx, in.Iface)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
dev, err := s.Store.GetDeviceByID(ctx, i.DeviceID)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
from, to, err := s.graphRange(in)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp := GraphResponse{
|
||||
Iface: GraphIface{
|
||||
ID: i.ID, IfName: i.IfName, IfAlias: i.IfAlias, SpeedBPS: speedBPS(i), Device: DeviceRef{Slug: dev.Slug, Name: dev.Name},
|
||||
},
|
||||
Partial: []TimeSpan{}, Series: GraphSeries{In: []Point{}, Out: []Point{}},
|
||||
}
|
||||
hotStart := s.Clock.Now().Add(-s.HotRetention)
|
||||
plan, err := planGraph(from, to, hotStart, time.Duration(in.Step)*time.Second)
|
||||
if err != nil {
|
||||
return nil, huma.Error422UnprocessableEntity(err.Error())
|
||||
}
|
||||
resp.StepSeconds, resp.Tier = int64(plan.newest().step/time.Second), plan.newest().tier
|
||||
if i.Unreliable {
|
||||
resp.Unreliable, resp.UnreliableReason = true, unreliableReason(i)
|
||||
return &struct{ Body GraphResponse }{resp}, nil
|
||||
}
|
||||
if err := s.fillGraph(ctx, &resp, i, plan, in, hotStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &struct{ Body GraphResponse }{resp}, nil
|
||||
}
|
||||
|
||||
// graphRange applies the defaults and validates the requested range.
|
||||
func (s *server) graphRange(in *graphIn) (from, to time.Time, err error) {
|
||||
to = in.To
|
||||
if to.IsZero() {
|
||||
to = s.Clock.Now()
|
||||
}
|
||||
from = in.From
|
||||
if from.IsZero() {
|
||||
from = to.Add(-time.Hour)
|
||||
}
|
||||
if !from.Before(to) {
|
||||
return from, to, huma.Error422UnprocessableEntity("from must be before to")
|
||||
}
|
||||
return from.UTC(), to.UTC(), nil
|
||||
}
|
||||
|
||||
// fillGraph queries the stores the plan names and lays the samples out on the grid.
|
||||
func (s *server) fillGraph(
|
||||
ctx context.Context, resp *GraphResponse, i *store.Interface, p *graphPlan, in *graphIn, hotStart time.Time,
|
||||
) error {
|
||||
release, err := s.admit(ctx, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer release()
|
||||
qctx, cancel := context.WithTimeout(ctx, QueryTimeout)
|
||||
defer cancel()
|
||||
q := &graphQuery{iface: i, dirs: []string{dirIn, dirOut}, withMax: in.Max}
|
||||
if in.Dir != "" {
|
||||
q.dirs = []string{in.Dir}
|
||||
}
|
||||
data := newGraphData()
|
||||
for _, w := range p.tiers {
|
||||
if err := s.fetchTier(qctx, q, w, data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
assemble(resp, p, q, data)
|
||||
if in.P95 {
|
||||
p95, err := s.livep95(qctx, q, p, hotStart)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
resp.P95 = p95
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assemble turns the samples into series, tiers, stitched_at and partial spans.
|
||||
func assemble(resp *GraphResponse, p *graphPlan, q *graphQuery, d *graphData) {
|
||||
grid := gridOf(p)
|
||||
for _, dir := range q.dirs {
|
||||
resp.Series.set(dir, false, pointsOf(grid, d.avg[dir]))
|
||||
if q.withMax {
|
||||
resp.Series.set(dir, true, pointsOf(grid, d.max[dir]))
|
||||
}
|
||||
}
|
||||
has := func(ts int64) bool {
|
||||
for _, dir := range q.dirs {
|
||||
if _, ok := d.avg[dir][ts]; ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
resp.Partial = append(resp.Partial, findGaps(grid, has, p.from.Unix(), p.to.Unix())...)
|
||||
if p.stitchedAt != nil {
|
||||
resp.StitchedAt = p.stitchedAt
|
||||
for _, w := range p.tiers {
|
||||
resp.Tiers = append(resp.Tiers, GraphTier{From: w.from.UTC(), To: w.bound.UTC(), Tier: w.tier, StepSeconds: int64(w.step / time.Second)})
|
||||
}
|
||||
}
|
||||
}
|
||||
163
internal/api/graph_fetch.go
Normal file
@@ -0,0 +1,163 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
)
|
||||
|
||||
const (
|
||||
dirIn = "in"
|
||||
dirOut = "out"
|
||||
// minRateWindow keeps the rate window above two poll intervals so a step of
|
||||
// one minute still sees two samples.
|
||||
minRateWindow = 2 * time.Minute
|
||||
// maxInnerStep is the resolution of the max-over-step subquery.
|
||||
maxInnerStep = time.Minute
|
||||
)
|
||||
|
||||
// graphData holds the samples found, by direction and Unix second.
|
||||
type graphData struct {
|
||||
avg map[string]map[int64]float64
|
||||
max map[string]map[int64]float64
|
||||
}
|
||||
|
||||
func newGraphData() *graphData {
|
||||
return &graphData{avg: map[string]map[int64]float64{}, max: map[string]map[int64]float64{}}
|
||||
}
|
||||
|
||||
func (g *graphData) put(isMax bool, dir string, ts int64, v float64) {
|
||||
m := g.avg
|
||||
if isMax {
|
||||
m = g.max
|
||||
}
|
||||
if m[dir] == nil {
|
||||
m[dir] = map[int64]float64{}
|
||||
}
|
||||
if old, ok := m[dir][ts]; !ok || v > old {
|
||||
m[dir][ts] = v // stores agree on duplicates; the highest wins
|
||||
}
|
||||
}
|
||||
|
||||
// graphQuery is what one fetch needs to know.
|
||||
type graphQuery struct {
|
||||
iface *store.Interface
|
||||
dirs []string
|
||||
withMax bool
|
||||
}
|
||||
|
||||
// octets is the counter that carries direction dir for the interface.
|
||||
func (q *graphQuery) octets(dir string) string {
|
||||
switch {
|
||||
case q.iface.HasHCCounters && dir == dirIn:
|
||||
return "ifHCInOctets"
|
||||
case q.iface.HasHCCounters:
|
||||
return "ifHCOutOctets"
|
||||
case dir == dirIn:
|
||||
return "ifInOctets"
|
||||
}
|
||||
return "ifOutOctets"
|
||||
}
|
||||
|
||||
func secs(d time.Duration) string { return strconv.FormatInt(int64(d/time.Second), 10) + "s" }
|
||||
|
||||
// rawExpr is the average bits per second of dir over the rate window.
|
||||
func (q *graphQuery) rawExpr(dir string, step time.Duration) string {
|
||||
sel := q.octets(dir) + `{iface=` + quote(q.iface.ID) + `}`
|
||||
return `rate(` + sel + `[` + secs(max(step, minRateWindow)) + `])*8`
|
||||
}
|
||||
|
||||
// rawMaxExpr is the highest one-minute rate seen within each step.
|
||||
func (q *graphQuery) rawMaxExpr(dir string, step time.Duration) string {
|
||||
sel := q.octets(dir) + `{iface=` + quote(q.iface.ID) + `}`
|
||||
return `max_over_time((rate(` + sel + `[` + secs(maxInnerStep) + `])*8)[` + secs(step) + `:` + secs(maxInnerStep) + `])`
|
||||
}
|
||||
|
||||
// longExpr reads an hourly rollup metric. Versions of a rewritten window share
|
||||
// the same value, so max by (dir) collapses them; a coarser step averages (or
|
||||
// takes the max of) the hours it covers.
|
||||
func (q *graphQuery) longExpr(metric, over string, dir string, step time.Duration) string {
|
||||
inner := `max by (dir) (` + metric + `{iface=` + quote(q.iface.ID) + `,dir=` + quote(dir) + `})`
|
||||
if step <= longStepMin {
|
||||
return inner
|
||||
}
|
||||
return over + `((` + inner + `)[` + secs(step) + `:` + secs(longStepMin) + `])`
|
||||
}
|
||||
|
||||
// fetchTier fills d from the store that owns the tier. Hot errors fail the
|
||||
// graph; a long store that is down only leaves its stretch empty, which the
|
||||
// response reports as partial.
|
||||
func (s *server) fetchTier(ctx context.Context, q *graphQuery, w tierWindow, d *graphData) error {
|
||||
storeName := "hot"
|
||||
if w.tier == tier1h {
|
||||
storeName = "long"
|
||||
}
|
||||
for _, dir := range q.dirs {
|
||||
for _, wantMax := range []bool{false, true} {
|
||||
if wantMax && !q.withMax {
|
||||
continue
|
||||
}
|
||||
err := s.fetchOne(ctx, storeName, q.exprFor(w, dir, wantMax), w, func(ts int64, v float64) { d.put(wantMax, dir, ts, v) })
|
||||
if err == nil {
|
||||
continue
|
||||
}
|
||||
if storeName == "long" && !errors.Is(err, context.Canceled) {
|
||||
s.Logger.Warn("long store query failed; its stretch is reported partial", "err", err)
|
||||
continue
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *graphQuery) exprFor(w tierWindow, dir string, wantMax bool) string {
|
||||
switch {
|
||||
case w.tier == tierRaw && wantMax:
|
||||
return q.rawMaxExpr(dir, w.step)
|
||||
case w.tier == tierRaw:
|
||||
return q.rawExpr(dir, w.step)
|
||||
case wantMax:
|
||||
return q.longExpr("nmon_if_rate_bps_max_1h", "max_over_time", dir, w.step)
|
||||
}
|
||||
return q.longExpr("nmon_if_rate_bps_avg_1h", "avg_over_time", dir, w.step)
|
||||
}
|
||||
|
||||
// fetchOne runs one range query over the tier's window and hands every finite sample to add.
|
||||
func (s *server) fetchOne(ctx context.Context, storeName, expr string, w tierWindow, add func(ts int64, v float64)) error {
|
||||
body, err := s.Querier.QueryRange(ctx, storeName, expr, w.from, w.last, w.step)
|
||||
if err != nil {
|
||||
return s.vmErr(ctx, err)
|
||||
}
|
||||
rows, err := parseVM(body)
|
||||
if err != nil {
|
||||
return fmt.Errorf("graph: %w", err)
|
||||
}
|
||||
for _, r := range rows {
|
||||
for _, p := range r.Values {
|
||||
if p.OK {
|
||||
add(p.TS, p.V)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// vmErr maps a metric-store failure to a problem. It is shared by /query and /graph.
|
||||
func (s *server) vmErr(ctx context.Context, err error) error {
|
||||
switch {
|
||||
case errors.Is(err, vmsup.ErrStoreDown):
|
||||
return errStoreDown()
|
||||
case errors.Is(err, context.DeadlineExceeded) || errors.Is(ctx.Err(), context.DeadlineExceeded):
|
||||
return errQueryTimeout()
|
||||
case isBadQuery(err):
|
||||
return badQuery(err)
|
||||
}
|
||||
s.Logger.Warn("query failed", "err", err)
|
||||
return errStoreFailed()
|
||||
}
|
||||
160
internal/api/graph_internal_test.go
Normal file
@@ -0,0 +1,160 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestDefaultStepIsMaxOf60sAndRangeOver1000RoundedToMinutes(t *testing.T) {
|
||||
for rng, want := range map[time.Duration]time.Duration{
|
||||
time.Hour: time.Minute, 16 * time.Hour: time.Minute, 17 * time.Hour: 2 * time.Minute,
|
||||
24 * time.Hour: 2 * time.Minute, 30 * 24 * time.Hour: 44 * time.Minute, 365 * 24 * time.Hour: 526 * time.Minute,
|
||||
} {
|
||||
if got := defaultStep(rng); got != want {
|
||||
t.Errorf("defaultStep(%s) = %s, want %s", rng, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlanGraphTiers(t *testing.T) {
|
||||
now := time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
|
||||
hotStart := now.Add(-30 * 24 * time.Hour)
|
||||
cases := []struct {
|
||||
name string
|
||||
from, to time.Time
|
||||
tiers []string
|
||||
stitched bool
|
||||
wantErr bool
|
||||
step time.Duration
|
||||
newestStep time.Duration
|
||||
}{
|
||||
{"1h", now.Add(-time.Hour), now, []string{"raw"}, false, false, 0, time.Minute},
|
||||
{"exactly the hot window", hotStart, now, []string{"raw"}, false, false, 0, 44 * time.Minute},
|
||||
{"a hair beyond hot is still raw", hotStart.Add(-20 * time.Minute), now, []string{"raw"}, false, false, 0, 44 * time.Minute},
|
||||
{"straddles", now.Add(-40 * 24 * time.Hour), now, []string{"1h", "raw"}, true, false, 0, 58 * time.Minute},
|
||||
{"ends inside hot start", now.Add(-60 * 24 * time.Hour), hotStart, []string{"1h"}, false, false, 0, time.Hour},
|
||||
{"old", now.Add(-90 * 24 * time.Hour), now.Add(-60 * 24 * time.Hour), []string{"1h"}, false, false, 0, time.Hour},
|
||||
{"too many points", now.Add(-30 * 24 * time.Hour), now, nil, false, true, time.Second, 0},
|
||||
{"explicit step", now.Add(-time.Hour), now, []string{"raw"}, false, false, 5 * time.Minute, 5 * time.Minute},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
p, err := planGraph(tc.from, tc.to, hotStart, tc.step)
|
||||
if (err != nil) != tc.wantErr {
|
||||
t.Errorf("%s: err %v", tc.name, err)
|
||||
continue
|
||||
}
|
||||
if tc.wantErr {
|
||||
continue
|
||||
}
|
||||
var got []string
|
||||
for _, w := range p.tiers {
|
||||
got = append(got, w.tier)
|
||||
}
|
||||
if len(got) != len(tc.tiers) || (p.stitchedAt != nil) != tc.stitched || p.newest().step != tc.newestStep {
|
||||
t.Errorf("%s: tiers %v stitched %v newest step %s, want %v %v %s", tc.name, got, p.stitchedAt, p.newest().step, tc.tiers, tc.stitched, tc.newestStep)
|
||||
continue
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.tiers[i] {
|
||||
t.Errorf("%s: tiers %v, want %v", tc.name, got, tc.tiers)
|
||||
}
|
||||
}
|
||||
if tc.stitched {
|
||||
join := *p.stitchedAt
|
||||
if join.Before(hotStart) || join.Sub(hotStart) >= p.newest().step || join.Unix()%int64(p.newest().step/time.Second) != 0 {
|
||||
t.Errorf("%s: join %v", tc.name, join)
|
||||
}
|
||||
if long := p.tiers[0]; long.last.Unix() >= join.Unix() || long.bound != join {
|
||||
t.Errorf("%s: long tier overlaps raw: %+v", tc.name, long)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindGapsThresholdIsTwoSteps(t *testing.T) {
|
||||
grid := make([]slot, 11)
|
||||
for i := range grid {
|
||||
grid[i] = slot{ts: int64(i * 60), step: 60}
|
||||
}
|
||||
has := func(missing ...int) func(int64) bool {
|
||||
return func(ts int64) bool {
|
||||
for _, m := range missing {
|
||||
if ts == int64(m*60) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
missing []int
|
||||
want [][2]int64
|
||||
}{
|
||||
{"complete", nil, nil},
|
||||
{"one missing sample", []int{4}, nil},
|
||||
{"two missing samples", []int{4, 5}, [][2]int64{{180, 360}}},
|
||||
{"leading", []int{0, 1, 2}, [][2]int64{{0, 180}}},
|
||||
{"leading short", []int{0, 1}, nil},
|
||||
{"trailing", []int{8, 9, 10}, [][2]int64{{420, 600}}},
|
||||
{"two separate holes", []int{2, 3, 7, 8}, [][2]int64{{60, 240}, {360, 540}}},
|
||||
} {
|
||||
got := findGaps(grid, has(tc.missing...), 0, 600)
|
||||
if len(got) != len(tc.want) {
|
||||
t.Errorf("%s: %v, want %v", tc.name, got, tc.want)
|
||||
continue
|
||||
}
|
||||
for i, g := range got {
|
||||
if g.From.Unix() != tc.want[i][0] || g.To.Unix() != tc.want[i][1] {
|
||||
t.Errorf("%s: gap %d is %v..%v, want %v", tc.name, i, g.From.Unix(), g.To.Unix(), tc.want[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
none := func(int64) bool { return false }
|
||||
if g := findGaps(grid, none, 0, 600); len(g) != 1 || g[0].From.Unix() != 0 || g[0].To.Unix() != 600 {
|
||||
t.Errorf("no data: %v", g)
|
||||
}
|
||||
if g := findGaps(nil, none, 0, 0); g != nil {
|
||||
t.Errorf("empty grid: %v", g)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNearestRank(t *testing.T) {
|
||||
seq := func(n int) []float64 {
|
||||
out := make([]float64, n)
|
||||
for i := range out {
|
||||
out[i] = float64(n - i) // descending, so sorting matters
|
||||
}
|
||||
return out
|
||||
}
|
||||
for n, want := range map[int]float64{1: 1, 2: 2, 19: 19, 20: 19, 21: 20, 100: 95, 101: 96, 1000: 950} {
|
||||
if got := nearestRank(seq(n), 0.95); got != want {
|
||||
t.Errorf("p95 of 1..%d = %v, want %v", n, got, want)
|
||||
}
|
||||
}
|
||||
if !math.IsNaN(nearestRank(nil, 0.95)) {
|
||||
t.Error("no values should be NaN")
|
||||
}
|
||||
in := []float64{3, 1, 2}
|
||||
nearestRank(in, 0.5)
|
||||
if in[0] != 3 {
|
||||
t.Error("input must not be reordered")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParentCycleHelpersAndEvidence(t *testing.T) {
|
||||
got := evidenceLines(`{"b":2,"a":"x","c":["y"]}`)
|
||||
if len(got) != 3 || got[0] != "a: x" || got[1] != "b: 2" || got[2] != `c: ["y"]` {
|
||||
t.Errorf("evidence: %q", got)
|
||||
}
|
||||
if got := evidenceLines(`not json`); len(got) != 1 || got[0] != "not json" {
|
||||
t.Errorf("evidence fallback: %q", got)
|
||||
}
|
||||
if got := evidenceLines(``); got == nil || len(got) != 0 {
|
||||
t.Errorf("empty evidence must be [] not nil: %#v", got)
|
||||
}
|
||||
if quote(`a"b\c`) != `"a\"b\\c"` {
|
||||
t.Errorf("quote: %s", quote(`a"b\c`))
|
||||
}
|
||||
}
|
||||
64
internal/api/graph_p95.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
p95Quantile = 0.95
|
||||
p95Slot = 5 * time.Minute
|
||||
// maxP95Span bounds the live computation to what one range query can return (about 25,000 slots).
|
||||
maxP95Span = 85 * 24 * time.Hour
|
||||
)
|
||||
|
||||
// livep95 computes the 95th percentile of 5-minute average rates over the
|
||||
// raw (hot) part of the drawn range, by nearest rank. M5 adds the materialised
|
||||
// monthly value; until then the source is "live" and coverage says how much of
|
||||
// the range's 5-minute slots had data, so a short history never passes for a full month.
|
||||
func (s *server) livep95(ctx context.Context, q *graphQuery, p *graphPlan, hotStart time.Time) (*GraphP95, error) {
|
||||
raw := p.newest()
|
||||
if raw.tier != tierRaw {
|
||||
return &GraphP95{Source: "live"}, nil
|
||||
}
|
||||
start := ceilTo(latest(raw.from, hotStart, raw.bound.Add(-maxP95Span)), p95Slot)
|
||||
end := floorTo(raw.bound, p95Slot)
|
||||
expected := max(int64(p.to.Sub(p.from)/p95Slot), 1)
|
||||
out := &GraphP95{Source: "live"}
|
||||
if end.Before(start) {
|
||||
return out, nil
|
||||
}
|
||||
w := tierWindow{tier: tierRaw, from: start, last: end, bound: end, step: p95Slot}
|
||||
coverage := 1.0
|
||||
for _, dir := range q.dirs {
|
||||
var vals []float64
|
||||
expr := `rate(` + q.octets(dir) + `{iface=` + quote(q.iface.ID) + `}[` + secs(p95Slot) + `])*8`
|
||||
err := s.fetchOne(ctx, "hot", expr, w, func(_ int64, v float64) { vals = append(vals, v) })
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
coverage = min(coverage, float64(len(vals))/float64(expected))
|
||||
if len(vals) == 0 {
|
||||
continue
|
||||
}
|
||||
v := nearestRank(vals, p95Quantile)
|
||||
if dir == dirIn {
|
||||
out.InBPS = &v
|
||||
} else {
|
||||
out.OutBPS = &v
|
||||
}
|
||||
}
|
||||
out.Coverage = min(coverage, 1)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// latest returns the latest of ts.
|
||||
func latest(ts ...time.Time) time.Time {
|
||||
out := ts[0]
|
||||
for _, t := range ts[1:] {
|
||||
if t.After(out) {
|
||||
out = t
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
119
internal/api/graph_plan.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
// maxGraphPoints bounds the points per series; the metric store caps a range query near 30,000.
|
||||
maxGraphPoints = 11_000
|
||||
// minRawStep is the default floor of a raw step: one poll interval.
|
||||
minRawStep = 60 * time.Second
|
||||
// targetPoints is how many points the default step aims for.
|
||||
targetPoints = 1000
|
||||
// longStepMin is the resolution of the long store's hourly rollup.
|
||||
longStepMin = time.Hour
|
||||
)
|
||||
|
||||
var errTooManyPoints = errors.New("too many points")
|
||||
|
||||
// tierWindow is one stretch of a graph answered by one store: points at
|
||||
// from, from+step, ... up to and including last. bound is where the tier
|
||||
// officially ends (the join for the older tier of a stitched graph).
|
||||
type tierWindow struct {
|
||||
tier string
|
||||
from time.Time
|
||||
last time.Time
|
||||
bound time.Time
|
||||
step time.Duration
|
||||
}
|
||||
|
||||
// graphPlan says which stores answer which part of [from, to]. Tiers are oldest first.
|
||||
type graphPlan struct {
|
||||
from, to time.Time // aligned bounds of the whole graph
|
||||
tiers []tierWindow
|
||||
stitchedAt *time.Time
|
||||
}
|
||||
|
||||
func (p *graphPlan) newest() tierWindow { return p.tiers[len(p.tiers)-1] }
|
||||
|
||||
// floorTo rounds t down to a multiple of d, counted from the Unix epoch so that
|
||||
// panning a graph never shifts its sample times.
|
||||
func floorTo(t time.Time, d time.Duration) time.Time {
|
||||
ds := int64(d / time.Second)
|
||||
return time.Unix(floorDiv(t.Unix(), ds)*ds, 0).UTC()
|
||||
}
|
||||
|
||||
func ceilTo(t time.Time, d time.Duration) time.Time {
|
||||
f := floorTo(t, d)
|
||||
if f.Equal(t) {
|
||||
return f
|
||||
}
|
||||
return f.Add(d)
|
||||
}
|
||||
|
||||
func floorDiv(a, b int64) int64 {
|
||||
q := a / b
|
||||
if a%b != 0 && (a < 0) != (b < 0) {
|
||||
q--
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
// defaultStep is max(60 s, range/1000) rounded up to whole minutes, so the
|
||||
// same range always lands on the same grid.
|
||||
func defaultStep(rng time.Duration) time.Duration {
|
||||
return max(minRawStep, ceilDur(rng/targetPoints, minRawStep))
|
||||
}
|
||||
|
||||
func ceilDur(d, unit time.Duration) time.Duration {
|
||||
return (d + unit - 1) / unit * unit
|
||||
}
|
||||
|
||||
// planGraph decides the tiers for [from, to] at the given step (0: default).
|
||||
// hotStart is where the hot store's retention begins. A range that lies within
|
||||
// hot (give or take one step) is answered by raw data alone, a range wholly
|
||||
// older than hot by hourly data alone, and one that straddles hotStart is
|
||||
// stitched: hourly before the join, raw from it.
|
||||
func planGraph(from, to, hotStart time.Time, step time.Duration) (*graphPlan, error) {
|
||||
if step <= 0 {
|
||||
step = defaultStep(to.Sub(from))
|
||||
}
|
||||
if n := to.Sub(from) / step; n > maxGraphPoints {
|
||||
return nil, fmt.Errorf("%w: %d points at a %s step; the limit is %d, use a larger step", errTooManyPoints, n, step, maxGraphPoints)
|
||||
}
|
||||
fromRaw, toRaw := floorTo(from, step), floorTo(to, step)
|
||||
long := max(longStepMin, ceilDur(step, longStepMin))
|
||||
switch {
|
||||
case !fromRaw.Add(step).Before(hotStart):
|
||||
return &graphPlan{from: fromRaw, to: toRaw, tiers: []tierWindow{
|
||||
{tier: tierRaw, from: fromRaw, last: toRaw, bound: toRaw, step: step},
|
||||
}}, nil
|
||||
case !toRaw.After(hotStart):
|
||||
fl, tl := floorTo(from, long), floorTo(to, long)
|
||||
return &graphPlan{from: fl, to: tl, tiers: []tierWindow{
|
||||
{tier: tier1h, from: fl, last: tl, bound: tl, step: long},
|
||||
}}, nil
|
||||
}
|
||||
join := ceilTo(hotStart, step)
|
||||
fl := floorTo(from, long)
|
||||
return &graphPlan{from: fl, to: toRaw, stitchedAt: &join, tiers: []tierWindow{
|
||||
{tier: tier1h, from: fl, last: floorTo(join.Add(-time.Nanosecond), long), bound: join, step: long},
|
||||
{tier: tierRaw, from: join, last: toRaw, bound: toRaw, step: step},
|
||||
}}, nil
|
||||
}
|
||||
|
||||
// slots lists the sample times of a tier.
|
||||
func (w tierWindow) slots() []int64 {
|
||||
if w.last.Before(w.from) {
|
||||
return nil
|
||||
}
|
||||
n := int(w.last.Sub(w.from)/w.step) + 1
|
||||
out := make([]int64, 0, n)
|
||||
for t := w.from; !t.After(w.last); t = t.Add(w.step) {
|
||||
out = append(out, t.Unix())
|
||||
}
|
||||
return out
|
||||
}
|
||||
80
internal/api/graph_series.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
)
|
||||
|
||||
// gapFactor is how many steps without data make a stretch "partial".
|
||||
const gapFactor = 2
|
||||
|
||||
// slot is one sample time of the graph and the step of the tier it belongs to.
|
||||
type slot struct {
|
||||
ts int64
|
||||
step int64
|
||||
}
|
||||
|
||||
// gridOf lists every sample time of the plan, oldest first.
|
||||
func gridOf(p *graphPlan) []slot {
|
||||
var out []slot
|
||||
for _, w := range p.tiers {
|
||||
step := int64(w.step / time.Second)
|
||||
for _, ts := range w.slots() {
|
||||
out = append(out, slot{ts: ts, step: step})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pointsOf lays vals over the grid; a slot without data is a null point.
|
||||
func pointsOf(grid []slot, vals map[int64]float64) []Point {
|
||||
out := make([]Point, len(grid))
|
||||
for i, g := range grid {
|
||||
out[i] = Point{TS: g.ts}
|
||||
if v, ok := vals[g.ts]; ok {
|
||||
x := v
|
||||
out[i].V = &x
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// findGaps returns the stretches of [start, end] longer than gapFactor steps in
|
||||
// which no slot has data. A stretch runs from the last slot with data (or the
|
||||
// start) to the next one (or the end); its step is the larger of its two
|
||||
// neighbours', so a join between tiers does not report a false gap.
|
||||
func findGaps(grid []slot, has func(ts int64) bool, start, end int64) []TimeSpan {
|
||||
if len(grid) == 0 {
|
||||
return nil
|
||||
}
|
||||
var gaps []TimeSpan
|
||||
prevTS, prevStep := start, grid[0].step
|
||||
report := func(from, to, step int64) {
|
||||
if to-from > gapFactor*step {
|
||||
gaps = append(gaps, TimeSpan{From: time.Unix(from, 0).UTC(), To: time.Unix(to, 0).UTC()})
|
||||
}
|
||||
}
|
||||
for _, g := range grid {
|
||||
if !has(g.ts) {
|
||||
continue
|
||||
}
|
||||
report(prevTS, g.ts, max(prevStep, g.step))
|
||||
prevTS, prevStep = g.ts, g.step
|
||||
}
|
||||
report(prevTS, end, prevStep)
|
||||
return gaps
|
||||
}
|
||||
|
||||
// nearestRank is the p-quantile (0 < p <= 1) of vals by the nearest-rank
|
||||
// method: the value at rank ceil(p*n) of the sorted list. It is exact, takes
|
||||
// an actual sample and needs no interpolation convention. NaN for no values.
|
||||
func nearestRank(vals []float64, p float64) float64 {
|
||||
if len(vals) == 0 {
|
||||
return math.NaN()
|
||||
}
|
||||
s := append([]float64(nil), vals...)
|
||||
sort.Float64s(s)
|
||||
rank := int(math.Ceil(p*float64(len(s)) - 1e-9)) // the epsilon keeps 0.95*20 from rounding up to 20
|
||||
return s[min(max(rank, 1), len(s))-1]
|
||||
}
|
||||
444
internal/api/graph_test.go
Normal file
@@ -0,0 +1,444 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
)
|
||||
|
||||
// graphVM fakes the two stores. hot and long say whether a store has a sample
|
||||
// at ts; the value is a function of direction and time so tests can check which store answered.
|
||||
type graphVM struct {
|
||||
hot, long func(ts int64) bool
|
||||
hotVal func(dir string, ts int64) float64
|
||||
longVal func(dir string, ts int64) float64
|
||||
longErr error
|
||||
}
|
||||
|
||||
func dirOf(expr string) string {
|
||||
if strings.Contains(expr, `dir="in"`) || strings.Contains(expr, "InOctets") {
|
||||
return "in"
|
||||
}
|
||||
return "out"
|
||||
}
|
||||
|
||||
func (g *graphVM) fn(storeName, expr string, from, to time.Time, step time.Duration) ([]byte, error) {
|
||||
has, val := g.hot, g.hotVal
|
||||
if storeName == "long" {
|
||||
if g.longErr != nil {
|
||||
return nil, g.longErr
|
||||
}
|
||||
has, val = g.long, g.longVal
|
||||
}
|
||||
dir := dirOf(expr)
|
||||
vals := map[int64]float64{}
|
||||
for ts := from.Unix(); ts <= to.Unix(); ts += int64(step / time.Second) {
|
||||
if has == nil || has(ts) {
|
||||
vals[ts] = val(dir, ts)
|
||||
}
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return emptyMatrix, nil
|
||||
}
|
||||
return matrix(mrow{labels: map[string]string{"dir": dir}, vals: vals}), nil
|
||||
}
|
||||
|
||||
func rising(dir string, ts int64) float64 {
|
||||
if dir == "in" {
|
||||
return 1e6 + float64(ts%100000)
|
||||
}
|
||||
return 2e6 + float64(ts%100000)
|
||||
}
|
||||
|
||||
func newGraphEnv(t *testing.T, vm *graphVM, mut ...func(*store.Interface)) (*env, store.Interface) {
|
||||
e := newEnv(t, 0)
|
||||
e.q.fn = vm.fn
|
||||
d := e.device(t, "Core", "", "")
|
||||
return e, e.iface(t, d, 1, "xe-0/0/1", "uplink", mut...)
|
||||
}
|
||||
|
||||
func iso(ts time.Time) string { return ts.UTC().Format(time.RFC3339) }
|
||||
|
||||
func graphURL(id string, from, to time.Time, extra string) string {
|
||||
return "/api/v1/graph?iface=" + id + "&from=" + url.QueryEscape(iso(from)) + "&to=" + url.QueryEscape(iso(to)) + extra
|
||||
}
|
||||
|
||||
func TestGraphRawTierWithinHotRetention(t *testing.T) {
|
||||
vm := &graphVM{hotVal: rising}
|
||||
e, i := newGraphEnv(t, vm)
|
||||
var g api.GraphResponse
|
||||
rec := e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, ""), &g)
|
||||
assertNoLeaks(t, "graph", rec.Body.Bytes())
|
||||
if g.Tier != "raw" || g.StepSeconds != 60 || g.StitchedAt != nil || len(g.Tiers) != 0 || g.Unreliable {
|
||||
t.Fatalf("raw graph: %+v", g)
|
||||
}
|
||||
if g.Partial == nil || len(g.Partial) != 0 {
|
||||
t.Errorf("partial must be [] when complete: %v", g.Partial)
|
||||
}
|
||||
if g.Iface.ID != i.ID || g.Iface.IfName != "xe-0/0/1" || g.Iface.IfAlias != "uplink" || g.Iface.SpeedBPS != 1_000_000_000 || g.Iface.Device.Name != "Core" {
|
||||
t.Errorf("iface: %+v", g.Iface)
|
||||
}
|
||||
if len(g.Series.In) != 61 || len(g.Series.Out) != 61 || g.Series.InMax != nil {
|
||||
t.Fatalf("series: %d in, %d out", len(g.Series.In), len(g.Series.Out))
|
||||
}
|
||||
first, last := g.Series.In[0], g.Series.In[60]
|
||||
if first.TS != t0.Add(-time.Hour).Unix() || last.TS != t0.Unix() || first.V == nil || *first.V != rising("in", first.TS) {
|
||||
t.Errorf("first %+v last %+v", first, last)
|
||||
}
|
||||
if *g.Series.Out[10].V != rising("out", g.Series.Out[10].TS) {
|
||||
t.Errorf("out series %+v", g.Series.Out[10])
|
||||
}
|
||||
qs := e.q.queries()
|
||||
if len(qs) != 2 || qs[0].store != "hot" || qs[0].step != time.Minute ||
|
||||
qs[0].expr != `rate(ifHCInOctets{iface="`+i.ID+`"}[120s])*8` || !strings.Contains(qs[1].expr, "ifHCOutOctets") {
|
||||
t.Errorf("queries: %+v", qs)
|
||||
}
|
||||
var raw map[string]json.RawMessage
|
||||
_ = json.Unmarshal(rec.Body.Bytes(), &raw)
|
||||
for _, k := range []string{"stitched_at", "tiers", "unreliable_reason", "p95"} {
|
||||
if _, ok := raw[k]; ok {
|
||||
t.Errorf("%s must be omitted from a plain raw graph", k)
|
||||
}
|
||||
}
|
||||
if !strings.HasPrefix(string(raw["series"]), `{"in":[[`) {
|
||||
t.Errorf("points must be [ts,value] pairs: %.60s", raw["series"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphUses32BitCountersWhenNoHC(t *testing.T) {
|
||||
e, i := newGraphEnv(t, &graphVM{hotVal: rising}, func(i *store.Interface) { i.HasHCCounters = false })
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, ""), &g)
|
||||
if q := e.q.queries()[0].expr; !strings.Contains(q, "ifInOctets{") || strings.Contains(q, "HC") {
|
||||
t.Errorf("32-bit query: %s", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphStepDefaultsAndLimits(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
rng time.Duration
|
||||
step int64
|
||||
}{{time.Hour, 60}, {24 * time.Hour, 120}, {7 * 24 * time.Hour, 660}, {30 * 24 * time.Hour, 2640}} {
|
||||
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, t0.Add(-tc.rng), t0, ""), &g)
|
||||
if g.StepSeconds != tc.step || g.Tier != "raw" || g.StitchedAt != nil {
|
||||
t.Errorf("range %s: step %d tier %s stitched %v, want step %d raw", tc.rng, g.StepSeconds, g.Tier, g.StitchedAt, tc.step)
|
||||
}
|
||||
if n := len(g.Series.In); n < 700 && tc.rng >= 24*time.Hour || n > 1001 {
|
||||
t.Errorf("range %s: %d points", tc.rng, n)
|
||||
}
|
||||
for _, p := range g.Series.In {
|
||||
if p.TS%g.StepSeconds != 0 {
|
||||
t.Fatalf("range %s: sample %d is not aligned to the %d s step", tc.rng, p.TS, g.StepSeconds)
|
||||
}
|
||||
}
|
||||
}
|
||||
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&step=300"), &g)
|
||||
if g.StepSeconds != 300 || len(g.Series.In) != 13 {
|
||||
t.Errorf("explicit step: %d, %d points", g.StepSeconds, len(g.Series.In))
|
||||
}
|
||||
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-30*24*time.Hour), t0, "&step=1"), ""), 422)
|
||||
}
|
||||
|
||||
func TestGraphStitchesHourlyLongDataToRawHot(t *testing.T) {
|
||||
join := t0.Add(-30 * 24 * time.Hour)
|
||||
vm := &graphVM{
|
||||
hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising,
|
||||
longVal: func(dir string, ts int64) float64 { return -1 }, // long values are recognisable
|
||||
}
|
||||
e, i := newGraphEnv(t, vm)
|
||||
from := t0.Add(-40 * 24 * time.Hour)
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, from, t0, "&max=true"), &g)
|
||||
if g.StitchedAt == nil || g.Tier != "raw" || len(g.Tiers) != 2 {
|
||||
t.Fatalf("stitched graph: stitched_at=%v tier=%s tiers=%+v", g.StitchedAt, g.Tier, g.Tiers)
|
||||
}
|
||||
if g.Tiers[0].Tier != "1h" || g.Tiers[0].StepSeconds != 3600 || g.Tiers[1].Tier != "raw" || g.Tiers[1].StepSeconds != g.StepSeconds {
|
||||
t.Errorf("tiers: %+v step=%d", g.Tiers, g.StepSeconds)
|
||||
}
|
||||
if !g.Tiers[0].To.Equal(*g.StitchedAt) || !g.Tiers[1].From.Equal(*g.StitchedAt) || g.StitchedAt.Before(join) ||
|
||||
g.StitchedAt.Sub(join) >= time.Duration(g.StepSeconds)*time.Second {
|
||||
t.Errorf("stitched_at %v should be the hot retention edge %v rounded up by less than a step", g.StitchedAt, join)
|
||||
}
|
||||
var long, rawN int
|
||||
prev := int64(0)
|
||||
for _, p := range g.Series.In {
|
||||
if p.TS <= prev {
|
||||
t.Fatalf("series not strictly increasing at %d", p.TS)
|
||||
}
|
||||
prev = p.TS
|
||||
if p.V == nil {
|
||||
t.Fatalf("hole at %d: both stores have data here", p.TS)
|
||||
}
|
||||
if *p.V == -1 {
|
||||
long++
|
||||
if p.TS >= g.StitchedAt.Unix() || p.TS%3600 != 0 {
|
||||
t.Errorf("long sample %d misplaced", p.TS)
|
||||
}
|
||||
} else {
|
||||
rawN++
|
||||
if p.TS < g.StitchedAt.Unix() {
|
||||
t.Errorf("raw sample %d before the join", p.TS)
|
||||
}
|
||||
}
|
||||
}
|
||||
if long < 200 || rawN < 400 {
|
||||
t.Errorf("%d long and %d raw samples", long, rawN)
|
||||
}
|
||||
if len(g.Partial) != 0 {
|
||||
t.Errorf("no gaps expected: %v", g.Partial)
|
||||
}
|
||||
if len(g.Series.InMax) != len(g.Series.In) || len(g.Series.OutMax) != len(g.Series.Out) {
|
||||
t.Errorf("max series lengths %d/%d", len(g.Series.InMax), len(g.Series.OutMax))
|
||||
}
|
||||
var sawLong, sawHot bool
|
||||
for _, c := range e.q.queries() {
|
||||
switch c.store {
|
||||
case "long":
|
||||
sawLong = true
|
||||
if !strings.Contains(c.expr, "max by (dir)") || !strings.Contains(c.expr, `iface="`+i.ID+`"`) ||
|
||||
(!strings.Contains(c.expr, "nmon_if_rate_bps_avg_1h") && !strings.Contains(c.expr, "nmon_if_rate_bps_max_1h")) {
|
||||
t.Errorf("long query: %s", c.expr)
|
||||
}
|
||||
case "hot":
|
||||
sawHot = true
|
||||
if c.from.Before(*g.StitchedAt) {
|
||||
t.Errorf("hot queried before the join: %v", c.from)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !sawLong || !sawHot {
|
||||
t.Error("a stitched graph must query both stores")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphLongRangeUsesCoarserStepAndHourlyAverages(t *testing.T) {
|
||||
e, i := newGraphEnv(t, &graphVM{hotVal: rising, longVal: rising})
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, t0.Add(-365*24*time.Hour), t0, ""), &g)
|
||||
if g.StitchedAt == nil || g.Tiers[0].StepSeconds%3600 != 0 || g.Tiers[0].StepSeconds <= 3600 {
|
||||
t.Fatalf("1y tiers: %+v", g.Tiers)
|
||||
}
|
||||
var longExpr string
|
||||
for _, c := range e.q.queries() {
|
||||
if c.store == "long" {
|
||||
longExpr = c.expr
|
||||
}
|
||||
}
|
||||
if !strings.Contains(longExpr, "avg_over_time((max by (dir)") || !strings.Contains(longExpr, ":3600s])") {
|
||||
t.Errorf("coarse long query should average the hourly points: %s", longExpr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphOlderThanHotIsLongTierOnly(t *testing.T) {
|
||||
vm := &graphVM{hot: func(int64) bool { return false }, hotVal: rising, longVal: rising}
|
||||
e, i := newGraphEnv(t, vm)
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, t0.Add(-90*24*time.Hour), t0.Add(-60*24*time.Hour), ""), &g)
|
||||
if g.Tier != "1h" || g.StepSeconds != 3600 || g.StitchedAt != nil || len(g.Tiers) != 0 || len(g.Series.In) < 700 {
|
||||
t.Errorf("long-only graph: tier=%s step=%d stitched=%v %d points", g.Tier, g.StepSeconds, g.StitchedAt, len(g.Series.In))
|
||||
}
|
||||
for _, c := range e.q.queries() {
|
||||
if c.store != "long" {
|
||||
t.Errorf("hot queried for a range wholly beyond hot retention: %+v", c)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphReportsPartialWhereNeitherStoreHasData(t *testing.T) {
|
||||
// Until M5's rollup exists the long store is empty: the stitched span is partial, honestly.
|
||||
join := t0.Add(-30 * 24 * time.Hour)
|
||||
vm := &graphVM{hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising, long: func(int64) bool { return false }}
|
||||
e, i := newGraphEnv(t, vm)
|
||||
from := t0.Add(-40 * 24 * time.Hour)
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, from, t0, ""), &g)
|
||||
if g.StitchedAt == nil || len(g.Partial) != 1 {
|
||||
t.Fatalf("partial: %+v stitched %v", g.Partial, g.StitchedAt)
|
||||
}
|
||||
p := g.Partial[0]
|
||||
if p.From.After(from.Add(time.Hour)) || p.To.Before(join) || p.To.After(join.Add(2*time.Hour)) {
|
||||
t.Errorf("gap %v..%v, want about %v..%v", p.From, p.To, from, join)
|
||||
}
|
||||
for _, pt := range g.Series.In {
|
||||
if pt.TS < g.StitchedAt.Unix() && pt.V != nil {
|
||||
t.Fatalf("a null expected before the join, got %v at %d", *pt.V, pt.TS)
|
||||
}
|
||||
}
|
||||
|
||||
// a short hole in raw data is a partial span; a single missed sample is not
|
||||
e2, i2 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(ts int64) bool {
|
||||
m := (ts - t0.Add(-time.Hour).Unix()) / 60
|
||||
return m < 20 || m > 50 && m != 55
|
||||
}})
|
||||
e2.getJSON(t, graphURL(i2.ID, t0.Add(-time.Hour), t0, ""), &g)
|
||||
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-41*time.Minute)) || !g.Partial[0].To.Equal(t0.Add(-9*time.Minute)) {
|
||||
t.Errorf("hole: %+v", g.Partial)
|
||||
}
|
||||
|
||||
// no data at all: one partial span covering the range
|
||||
e3, i3 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(int64) bool { return false }})
|
||||
e3.getJSON(t, graphURL(i3.ID, t0.Add(-time.Hour), t0, ""), &g)
|
||||
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-time.Hour)) || !g.Partial[0].To.Equal(t0) {
|
||||
t.Errorf("empty: %+v", g.Partial)
|
||||
}
|
||||
// data that starts late: a leading gap
|
||||
e4, i4 := newGraphEnv(t, &graphVM{hotVal: rising, hot: func(ts int64) bool { return ts >= t0.Add(-20*time.Minute).Unix() }})
|
||||
e4.getJSON(t, graphURL(i4.ID, t0.Add(-time.Hour), t0, ""), &g)
|
||||
if len(g.Partial) != 1 || !g.Partial[0].From.Equal(t0.Add(-time.Hour)) || !g.Partial[0].To.Equal(t0.Add(-20*time.Minute)) {
|
||||
t.Errorf("leading gap: %+v", g.Partial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphLongStoreDownIsPartialNotAnError(t *testing.T) {
|
||||
join := t0.Add(-30 * 24 * time.Hour)
|
||||
vm := &graphVM{hot: func(ts int64) bool { return ts >= join.Unix() }, hotVal: rising, longErr: fmt.Errorf("%w: long", vmsup.ErrStoreDown)}
|
||||
e, i := newGraphEnv(t, vm)
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, t0.Add(-40*24*time.Hour), t0, ""), &g)
|
||||
if g.StitchedAt == nil || len(g.Partial) != 1 {
|
||||
t.Errorf("long store down: %+v", g.Partial)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphUnreliableInterfaceHasNoSeries(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
mut func(*store.Interface)
|
||||
reason string
|
||||
}{
|
||||
"32-bit fast link": {func(i *store.Interface) { i.Unreliable, i.HasHCCounters = true, false }, "counter_32bit_fast_link"},
|
||||
"discontinuities": {func(i *store.Interface) { i.Unreliable, i.CounterDiscontinuity = true, 3 }, "counter_discontinuity"},
|
||||
"unreliable for HC": {func(i *store.Interface) { i.Unreliable = true }, "counter_unreliable"},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
e, i := newGraphEnv(t, &graphVM{hotVal: rising}, tc.mut)
|
||||
var g api.GraphResponse
|
||||
rec := e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&max=true&p95=true"), &g)
|
||||
if !g.Unreliable || g.UnreliableReason != tc.reason || len(g.Series.In) != 0 || len(g.Series.Out) != 0 ||
|
||||
g.P95 != nil || g.Partial == nil || g.Tier != "raw" || g.StepSeconds != 60 {
|
||||
t.Errorf("unreliable graph: %+v", g)
|
||||
}
|
||||
if n := len(e.q.queries()); n != 0 {
|
||||
t.Errorf("an unreliable interface must not be queried, %d queries", n)
|
||||
}
|
||||
if !strings.Contains(rec.Body.String(), `"in":[],"out":[]`) {
|
||||
t.Errorf("series must be empty arrays: %s", rec.Body)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphDirectionFilterAndMax(t *testing.T) {
|
||||
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, t0.Add(-time.Hour), t0, "&dir=out&max=true"), &g)
|
||||
if len(g.Series.In) != 0 || len(g.Series.Out) != 61 || len(g.Series.OutMax) != 61 || g.Series.InMax != nil {
|
||||
t.Errorf("dir=out: in=%d out=%d out_max=%d", len(g.Series.In), len(g.Series.Out), len(g.Series.OutMax))
|
||||
}
|
||||
qs := e.q.queries()
|
||||
if len(qs) != 2 {
|
||||
t.Fatalf("queries %d", len(qs))
|
||||
}
|
||||
for _, c := range qs {
|
||||
if strings.Contains(c.expr, "In") {
|
||||
t.Errorf("dir=out queried the in counter: %s", c.expr)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(qs[1].expr, "max_over_time((rate(ifHCOutOctets{iface=") || !strings.Contains(qs[1].expr, "[60s:60s])") {
|
||||
t.Errorf("max query: %s", qs[1].expr)
|
||||
}
|
||||
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, "&dir=sideways"), ""), 422)
|
||||
}
|
||||
|
||||
// referenceP95 is an independent nearest-rank implementation: the smallest
|
||||
// value such that at least 95% of the values are at or below it.
|
||||
func referenceP95(vals []float64) float64 {
|
||||
s := append([]float64(nil), vals...)
|
||||
sort.Float64s(s)
|
||||
for _, v := range s {
|
||||
n := 0
|
||||
for _, x := range s {
|
||||
if x <= v {
|
||||
n++
|
||||
}
|
||||
}
|
||||
if float64(n)*100 >= 95*float64(len(s)) {
|
||||
return v
|
||||
}
|
||||
}
|
||||
return math.NaN()
|
||||
}
|
||||
|
||||
func TestGraphP95IsNearestRankOverFiveMinuteAverages(t *testing.T) {
|
||||
// 24 h of 5-minute averages; one hour of the day has no data.
|
||||
from := t0.Add(-24 * time.Hour)
|
||||
vals := func(dir string, ts int64) float64 {
|
||||
k := float64((ts / 300 * 7919) % 1000) // scrambled but deterministic
|
||||
if dir == "out" {
|
||||
k *= 3
|
||||
}
|
||||
return k * 1000
|
||||
}
|
||||
hole := func(ts int64) bool { return ts < t0.Add(-5*time.Hour).Unix() || ts >= t0.Add(-4*time.Hour).Unix() }
|
||||
e, i := newGraphEnv(t, &graphVM{hotVal: vals, hot: hole})
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, from, t0, "&p95=true"), &g)
|
||||
if g.P95 == nil || g.P95.Source != "live" || g.P95.InBPS == nil || g.P95.OutBPS == nil {
|
||||
t.Fatalf("p95: %+v", g.P95)
|
||||
}
|
||||
var in, out []float64
|
||||
for ts := from.Unix(); ts <= t0.Unix(); ts += 300 {
|
||||
if hole(ts) {
|
||||
in, out = append(in, vals("in", ts)), append(out, vals("out", ts))
|
||||
}
|
||||
}
|
||||
if *g.P95.InBPS != referenceP95(in) || *g.P95.OutBPS != referenceP95(out) {
|
||||
t.Errorf("p95 in %v out %v, reference %v / %v", *g.P95.InBPS, *g.P95.OutBPS, referenceP95(in), referenceP95(out))
|
||||
}
|
||||
wantCov := float64(len(in)) / (24 * 12)
|
||||
if math.Abs(g.P95.Coverage-wantCov) > 0.01 || g.P95.Coverage >= 1 {
|
||||
t.Errorf("coverage %v, want about %v", g.P95.Coverage, wantCov)
|
||||
}
|
||||
var withoutP95 api.GraphResponse
|
||||
e.getJSON(t, graphURL(i.ID, from, t0, ""), &withoutP95)
|
||||
if withoutP95.P95 != nil {
|
||||
t.Error("p95 only when asked")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphErrors(t *testing.T) {
|
||||
e, i := newGraphEnv(t, &graphVM{hotVal: rising})
|
||||
isProblem(t, e.do("GET", "/api/v1/graph", ""), 422)
|
||||
isProblem(t, e.do("GET", "/api/v1/graph?iface=01ARZ3NDEKTSV4RRFFQ69G5FAV", ""), 404)
|
||||
isProblem(t, e.do("GET", graphURL(i.ID, t0, t0.Add(-time.Hour), ""), ""), 422)
|
||||
isProblem(t, e.do("GET", "/api/v1/graph?iface="+i.ID+"&from=yesterday", ""), 422)
|
||||
isProblem(t, e.do("GET", "/api/v1/graph?iface="+i.ID+"&step=0", ""), 422)
|
||||
e.q.fn = func(string, string, time.Time, time.Time, time.Duration) ([]byte, error) {
|
||||
return nil, fmt.Errorf("%w: hot", vmsup.ErrStoreDown)
|
||||
}
|
||||
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, ""), ""), 503)
|
||||
e.q.fn = func(string, string, time.Time, time.Time, time.Duration) ([]byte, error) {
|
||||
return nil, errors.New("boom")
|
||||
}
|
||||
isProblem(t, e.do("GET", graphURL(i.ID, t0.Add(-time.Hour), t0, ""), ""), 502)
|
||||
// defaults: to=now, from=1h earlier
|
||||
e.q.fn = (&graphVM{hotVal: rising}).fn
|
||||
var g api.GraphResponse
|
||||
e.getJSON(t, "/api/v1/graph?iface="+i.ID, &g)
|
||||
if len(g.Series.In) != 61 || g.Series.In[60].TS != t0.Unix() {
|
||||
t.Errorf("default range: %d points", len(g.Series.In))
|
||||
}
|
||||
}
|
||||
125
internal/api/graph_types.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
)
|
||||
|
||||
// Graph tiers: raw samples from the hot store, or hourly rollups from the long store.
|
||||
const (
|
||||
tierRaw = "raw"
|
||||
tier1h = "1h"
|
||||
)
|
||||
|
||||
// Point is one sample of a series: Unix seconds and bits per second, or null
|
||||
// when the store had no data for that step. It marshals as [ts, value].
|
||||
type Point struct {
|
||||
TS int64
|
||||
V *float64
|
||||
}
|
||||
|
||||
// MarshalJSON writes the compact [unix_s, bps|null] form uPlot can use as is.
|
||||
func (p Point) MarshalJSON() ([]byte, error) {
|
||||
return json.Marshal([]any{p.TS, p.V})
|
||||
}
|
||||
|
||||
// UnmarshalJSON reads the [ts, value] form (tests and clients written in Go).
|
||||
func (p *Point) UnmarshalJSON(b []byte) error {
|
||||
var raw [2]json.RawMessage
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := json.Unmarshal(raw[0], &p.TS); err != nil {
|
||||
return err
|
||||
}
|
||||
return json.Unmarshal(raw[1], &p.V)
|
||||
}
|
||||
|
||||
// Schema describes Point as a two-element tuple. huma's Schema has no
|
||||
// prefixItems field, so it goes in as an extension; generated clients type it as [number, number | null].
|
||||
func (Point) Schema(r huma.Registry) *huma.Schema {
|
||||
two := 2
|
||||
r.Map()["Point"] = &huma.Schema{
|
||||
Type: huma.TypeArray, MinItems: &two, MaxItems: &two,
|
||||
Description: "[unix seconds, bits per second or null].",
|
||||
Extensions: map[string]any{"prefixItems": []any{
|
||||
map[string]any{"type": "number", "description": "unix seconds"},
|
||||
map[string]any{"anyOf": []any{
|
||||
map[string]any{"type": "number", "description": "bits per second"}, map[string]any{"type": "null"},
|
||||
}},
|
||||
}},
|
||||
}
|
||||
return &huma.Schema{Ref: "#/components/schemas/Point"}
|
||||
}
|
||||
|
||||
// GraphIface identifies the graphed interface.
|
||||
type GraphIface struct {
|
||||
ID string `json:"id"`
|
||||
IfName string `json:"ifname"`
|
||||
IfAlias string `json:"ifalias"`
|
||||
SpeedBPS int64 `json:"speed_bps"`
|
||||
Device DeviceRef `json:"device"`
|
||||
}
|
||||
|
||||
// TimeSpan is a half-open stretch of time.
|
||||
type TimeSpan struct {
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
}
|
||||
|
||||
// GraphTier says which store and step answered one stretch of a stitched graph.
|
||||
type GraphTier struct {
|
||||
From time.Time `json:"from"`
|
||||
To time.Time `json:"to"`
|
||||
Tier string `json:"tier" enum:"raw,1h"`
|
||||
StepSeconds int64 `json:"step_seconds"`
|
||||
}
|
||||
|
||||
// GraphSeries holds the bits-per-second series. in and out are always present
|
||||
// (empty for a direction not asked for); the max series only when requested.
|
||||
type GraphSeries struct {
|
||||
In []Point `json:"in"`
|
||||
Out []Point `json:"out"`
|
||||
InMax []Point `json:"in_max,omitempty"`
|
||||
OutMax []Point `json:"out_max,omitempty"`
|
||||
}
|
||||
|
||||
// set stores pts as the average (or maximum) series of dir.
|
||||
func (g *GraphSeries) set(dir string, isMax bool, pts []Point) {
|
||||
switch {
|
||||
case dir == dirIn && isMax:
|
||||
g.InMax = pts
|
||||
case dir == dirIn:
|
||||
g.In = pts
|
||||
case isMax:
|
||||
g.OutMax = pts
|
||||
default:
|
||||
g.Out = pts
|
||||
}
|
||||
}
|
||||
|
||||
// GraphP95 is the 95th percentile of 5-minute averages (nearest rank).
|
||||
type GraphP95 struct {
|
||||
InBPS *float64 `json:"in_bps" nullable:"true"`
|
||||
OutBPS *float64 `json:"out_bps" nullable:"true"`
|
||||
Source string `json:"source" enum:"materialised,live"`
|
||||
Coverage float64 `json:"coverage" doc:"Share of the drawn range's 5-minute slots that had data, 0 to 1."`
|
||||
}
|
||||
|
||||
// GraphResponse is a rate graph for one interface. A graph answered from two
|
||||
// stores always carries stitched_at; stretches with no data in either store are
|
||||
// listed in partial; an unreliable interface has no series at all.
|
||||
type GraphResponse struct {
|
||||
Iface GraphIface `json:"iface"`
|
||||
StepSeconds int64 `json:"step_seconds" doc:"Step of the newest tier."`
|
||||
Tier string `json:"tier" enum:"raw,1h" doc:"Tier of the newest data."`
|
||||
StitchedAt *time.Time `json:"stitched_at,omitempty" doc:"Where the hourly long-store data ends and raw hot data begins."`
|
||||
Tiers []GraphTier `json:"tiers,omitempty" doc:"Present when two stores answered."`
|
||||
UnreliableReason string `json:"unreliable_reason,omitempty" doc:"Why the counters are not trusted, e.g. counter_32bit_fast_link."`
|
||||
Partial []TimeSpan `json:"partial" doc:"Stretches of the range where neither store has data."`
|
||||
Unreliable bool `json:"unreliable"`
|
||||
Series GraphSeries `json:"series"`
|
||||
P95 *GraphP95 `json:"p95,omitempty"`
|
||||
}
|
||||
184
internal/api/health.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/danielgtaylor/huma/v2"
|
||||
|
||||
"github.com/v10networks/nmon/internal/metrics"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
)
|
||||
|
||||
// PollerDeviceHealth is the poller's view of one device. It uses the slug, never the numeric ID.
|
||||
type PollerDeviceHealth struct {
|
||||
Slug string `json:"slug"`
|
||||
Name string `json:"name"`
|
||||
State string `json:"state" enum:"ok,degraded,backoff" doc:"backoff: healthy but polled slower than its base interval."`
|
||||
Varbinds int64 `json:"varbinds" doc:"Varbinds per request right now."`
|
||||
IntervalS int64 `json:"interval_s" doc:"Current cadence, backoff included."`
|
||||
BaseIntervalS int64 `json:"base_interval_s"`
|
||||
LastCycleS float64 `json:"last_cycle_s" doc:"Duration of the last cycle."`
|
||||
MissedDeadlines int64 `json:"missed_deadlines" doc:"Consecutive cycles that ended at the deadline."`
|
||||
ConsecutiveFailures int64 `json:"consecutive_failures"`
|
||||
LastError string `json:"last_error"`
|
||||
NextCycle *time.Time `json:"next_cycle" nullable:"true"`
|
||||
Profile string `json:"profile"`
|
||||
}
|
||||
|
||||
// PollerTotals are the poller's aggregate numbers. The per-minute figures are
|
||||
// deltas of nmon's own counters over about the last minute.
|
||||
type PollerTotals struct {
|
||||
PDUsPerMin float64 `json:"pdus_per_min"`
|
||||
TimeoutsPerMin float64 `json:"timeouts_per_min"`
|
||||
DeadlineMissesPerMin float64 `json:"deadline_misses_per_min"`
|
||||
WriteQueueDepth int64 `json:"write_queue_depth" doc:"Samples waiting in the hot write queue."`
|
||||
WriteQueueCapacity int64 `json:"write_queue_capacity"`
|
||||
DevicesDegraded int64 `json:"devices_degraded"`
|
||||
}
|
||||
|
||||
// PollerHealth is the poller health page.
|
||||
type PollerHealth struct {
|
||||
Devices []PollerDeviceHealth `json:"devices"`
|
||||
Totals PollerTotals `json:"totals"`
|
||||
}
|
||||
|
||||
func (s *server) registerHealth(api huma.API) {
|
||||
huma.Register(api, huma.Operation{
|
||||
OperationID: "get-poller-health", Method: http.MethodGet, Path: "/api/v1/poller/health", Tags: []string{"poller"},
|
||||
Summary: "Poller state of every polled device, with PDU, timeout and queue totals",
|
||||
Description: "Rates are over about the last minute; the first call after start may report zero.",
|
||||
}, s.pollerHealth)
|
||||
}
|
||||
|
||||
func (s *server) pollerHealth(ctx context.Context, _ *struct{}) (*struct{ Body PollerHealth }, error) {
|
||||
names, err := s.deviceNames(ctx)
|
||||
if err != nil {
|
||||
return nil, s.storeErr(err)
|
||||
}
|
||||
out := PollerHealth{Devices: []PollerDeviceHealth{}}
|
||||
if s.PollerStatus != nil {
|
||||
for _, d := range s.PollerStatus() {
|
||||
h := PollerDeviceHealth{
|
||||
Slug: d.Device, Name: names[d.Device], State: d.State, Varbinds: int64(d.Varbinds),
|
||||
IntervalS: int64(d.Interval.Seconds()), BaseIntervalS: int64(d.BaseInterval.Seconds()),
|
||||
LastCycleS: d.LastCycleDuration.Seconds(), MissedDeadlines: int64(d.MissedDeadlines),
|
||||
ConsecutiveFailures: int64(d.ConsecutiveFailures), LastError: d.LastError, NextCycle: tp(d.NextCycle),
|
||||
Profile: d.Profile,
|
||||
}
|
||||
if h.State == store.PollOK && d.Interval > d.BaseInterval {
|
||||
h.State = store.PollBackoff
|
||||
}
|
||||
if h.State != store.PollOK {
|
||||
out.Totals.DevicesDegraded++
|
||||
}
|
||||
out.Devices = append(out.Devices, h)
|
||||
}
|
||||
}
|
||||
s.fillTotals(&out.Totals)
|
||||
return &struct{ Body PollerHealth }{out}, nil
|
||||
}
|
||||
|
||||
// deviceNames maps slugs to display names.
|
||||
func (s *server) deviceNames(ctx context.Context) (map[string]string, error) {
|
||||
names := map[string]string{}
|
||||
for off := int64(0); ; off += store.MaxLimit {
|
||||
rows, n, err := s.Store.ListDevices(ctx, store.DeviceFilter{IncludeRetired: true}, store.Page{Offset: off, Limit: store.MaxLimit})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, d := range rows {
|
||||
names[d.Slug] = d.Name
|
||||
}
|
||||
if off+store.MaxLimit >= n {
|
||||
return names, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// fillTotals reads nmon's own counters from the registry.
|
||||
func (s *server) fillTotals(t *PollerTotals) {
|
||||
t.PDUsPerMin, t.TimeoutsPerMin, t.DeadlineMissesPerMin = s.rate.perMinute(s.readCounters())
|
||||
for _, x := range s.Registry.Snapshot(s.Clock.Now()) {
|
||||
if x.Name == "nmon_write_queue_depth" && labelValue(x.Labels, "queue") == "hot" {
|
||||
t.WriteQueueDepth += int64(x.Value)
|
||||
}
|
||||
}
|
||||
t.WriteQueueCapacity = int64(s.WriteQueueCapacity)
|
||||
}
|
||||
|
||||
// counterSample is the registry's counter totals at one instant.
|
||||
type counterSample struct {
|
||||
at time.Time
|
||||
pdus, timeouts, misses float64
|
||||
}
|
||||
|
||||
// readCounters sums the poll-path counters across all their series.
|
||||
func (s *server) readCounters() counterSample {
|
||||
c := counterSample{at: s.Clock.Now()}
|
||||
for _, x := range s.Registry.Snapshot(c.at) {
|
||||
switch x.Name {
|
||||
case "nmon_pdus_total":
|
||||
if labelValue(x.Labels, "path") == "poll" {
|
||||
c.pdus += x.Value
|
||||
}
|
||||
case "nmon_snmp_timeouts_total":
|
||||
if labelValue(x.Labels, "path") == "poll" {
|
||||
c.timeouts += x.Value
|
||||
}
|
||||
case "nmon_poll_deadline_missed_total":
|
||||
c.misses += x.Value
|
||||
}
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
// counterRates turns successive counter totals into per-minute rates. It keeps
|
||||
// about a minute and a half of history and measures from the oldest sample, so
|
||||
// concurrent pollers of the page do not shrink the window.
|
||||
type counterRates struct {
|
||||
mu sync.Mutex
|
||||
samples []counterSample
|
||||
}
|
||||
|
||||
const rateHistory = 90 * time.Second
|
||||
|
||||
// perMinute records cur and returns the per-minute rates since the oldest retained sample.
|
||||
func (c *counterRates) perMinute(cur counterSample) (pdus, timeouts, misses float64) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
for len(c.samples) > 0 && cur.at.Sub(c.samples[0].at) > rateHistory {
|
||||
c.samples = c.samples[1:]
|
||||
}
|
||||
defer func() { c.samples = append(c.samples, cur) }()
|
||||
if len(c.samples) == 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
base := c.samples[0]
|
||||
span := cur.at.Sub(base.at).Minutes()
|
||||
if span <= 0 {
|
||||
return 0, 0, 0
|
||||
}
|
||||
per := func(now, then float64) float64 { return max(now-then, 0) / span }
|
||||
return per(cur.pdus, base.pdus), per(cur.timeouts, base.timeouts), per(cur.misses, base.misses)
|
||||
}
|
||||
|
||||
func labelValue(ls []metrics.Label, name string) string {
|
||||
for _, l := range ls {
|
||||
if l.Name == name {
|
||||
return l.Value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// tp is t in UTC, or nil for the zero time.
|
||||
func tp(t time.Time) *time.Time {
|
||||
if t.IsZero() {
|
||||
return nil
|
||||
}
|
||||
u := t.UTC()
|
||||
return &u
|
||||
}
|
||||
180
internal/api/helpers_test.go
Normal file
@@ -0,0 +1,180 @@
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
"github.com/v10networks/nmon/internal/ulid"
|
||||
)
|
||||
|
||||
// device stores a network device and returns it with its generated slug.
|
||||
func (e *env) device(t *testing.T, name, site, role string, mut ...func(*store.Device)) *store.Device {
|
||||
t.Helper()
|
||||
d := &store.Device{Name: name, Kind: store.KindNetwork, Site: site, Role: role, Enabled: true, Profile: "generic", MgmtIP: "192.0.2.1"}
|
||||
for _, m := range mut {
|
||||
m(d)
|
||||
}
|
||||
if err := e.st.CreateDevice(context.Background(), d); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
// iface stores one interface of d and returns it.
|
||||
func (e *env) iface(t *testing.T, d *store.Device, idx int64, name, alias string, mut ...func(*store.Interface)) store.Interface {
|
||||
t.Helper()
|
||||
i := store.Interface{
|
||||
ID: ulid.New(e.clk.Now()), DeviceID: d.ID, IfIndex: idx, IfName: name, IfDescr: name, IfAlias: alias,
|
||||
IfSpeed: 1_000_000_000, IfHighSpeed: 1000, AdminStatus: 1, OperStatus: 1, HasHCCounters: true,
|
||||
FirstSeen: e.clk.Now(), LastDiscovered: e.clk.Now(),
|
||||
}
|
||||
for _, m := range mut {
|
||||
m(&i)
|
||||
}
|
||||
if err := e.st.UpsertInterfaces(context.Background(), d.ID, []store.Interface{i}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return i
|
||||
}
|
||||
|
||||
// getJSON GETs path and decodes a 200 body into v.
|
||||
func (e *env) getJSON(t *testing.T, path string, v any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
rec := e.do("GET", path, "")
|
||||
if rec.Code != 200 {
|
||||
t.Fatalf("GET %s: %d %s", path, rec.Code, rec.Body)
|
||||
}
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), v); err != nil {
|
||||
t.Fatalf("GET %s: %v: %s", path, err, rec.Body)
|
||||
}
|
||||
return rec
|
||||
}
|
||||
|
||||
// vector builds an instant-query response from rows of (labels, value).
|
||||
type vrow struct {
|
||||
labels map[string]string
|
||||
v float64
|
||||
}
|
||||
|
||||
func vector(rows ...vrow) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`{"status":"success","data":{"resultType":"vector","result":[`)
|
||||
for i, r := range rows {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
lb, _ := json.Marshal(r.labels)
|
||||
fmt.Fprintf(&b, `{"metric":%s,"value":[1,"%v"]}`, lb, r.v)
|
||||
}
|
||||
b.WriteString(`]}}`)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
// matrix builds a range-query response with one series per entry of rows:
|
||||
// labels and a map of unix second to value.
|
||||
type mrow struct {
|
||||
labels map[string]string
|
||||
vals map[int64]float64
|
||||
}
|
||||
|
||||
func matrix(rows ...mrow) []byte {
|
||||
var b bytes.Buffer
|
||||
b.WriteString(`{"status":"success","data":{"resultType":"matrix","result":[`)
|
||||
for i, r := range rows {
|
||||
if i > 0 {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
lb, _ := json.Marshal(r.labels)
|
||||
fmt.Fprintf(&b, `{"metric":%s,"values":[`, lb)
|
||||
first := true
|
||||
for _, ts := range sortedKeys(r.vals) {
|
||||
if !first {
|
||||
b.WriteByte(',')
|
||||
}
|
||||
first = false
|
||||
fmt.Fprintf(&b, `[%d,"%v"]`, ts, r.vals[ts])
|
||||
}
|
||||
b.WriteString(`]}`)
|
||||
}
|
||||
b.WriteString(`]}}`)
|
||||
return b.Bytes()
|
||||
}
|
||||
|
||||
func sortedKeys(m map[int64]float64) []int64 {
|
||||
out := make([]int64, 0, len(m))
|
||||
for k := range m {
|
||||
out = append(out, k)
|
||||
}
|
||||
for i := 1; i < len(out); i++ {
|
||||
for j := i; j > 0 && out[j] < out[j-1]; j-- {
|
||||
out[j], out[j-1] = out[j-1], out[j]
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// emptyMatrix is a range response with no series.
|
||||
var emptyMatrix = []byte(`{"status":"success","data":{"resultType":"matrix","result":[]}}`)
|
||||
|
||||
// walkJSON calls fn for every key/value of every object in the JSON document.
|
||||
func walkJSON(t *testing.T, body []byte, fn func(path, key string, val any)) {
|
||||
t.Helper()
|
||||
var doc any
|
||||
if err := json.Unmarshal(body, &doc); err != nil {
|
||||
t.Fatalf("not JSON: %v: %s", err, body)
|
||||
}
|
||||
var rec func(path string, v any)
|
||||
rec = func(path string, v any) {
|
||||
switch x := v.(type) {
|
||||
case map[string]any:
|
||||
for k, c := range x {
|
||||
fn(path, k, c)
|
||||
rec(path+"."+k, c)
|
||||
}
|
||||
case []any:
|
||||
for i, c := range x {
|
||||
rec(fmt.Sprintf("%s[%d]", path, i), c)
|
||||
}
|
||||
}
|
||||
}
|
||||
rec("$", doc)
|
||||
}
|
||||
|
||||
// assertNoLeaks fails when body has a numeric id, a credential field or a secret.
|
||||
func assertNoLeaks(t *testing.T, what string, body []byte, secrets ...string) {
|
||||
t.Helper()
|
||||
walkJSON(t, body, func(path, key string, val any) {
|
||||
lower := strings.ToLower(key)
|
||||
if _, isNum := val.(float64); isNum && (lower == "id" || strings.HasSuffix(lower, "_id") || lower == "device_id") {
|
||||
t.Errorf("%s: numeric id at %s.%s = %v", what, path, key, val)
|
||||
}
|
||||
for _, bad := range []string{"community", "password", "pass", "hash", "secret", "token", "credential"} {
|
||||
if strings.Contains(lower, bad) {
|
||||
t.Errorf("%s: credential-like key %s.%s", what, path, key)
|
||||
}
|
||||
}
|
||||
})
|
||||
for _, s := range secrets {
|
||||
if bytes.Contains(body, []byte(s)) {
|
||||
t.Errorf("%s: response contains secret %q", what, s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var t0 = time.Date(2026, 10, 22, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
// doFrom is do with a chosen remote address, which the admission limits key on.
|
||||
func (e *env) doFrom(method, path, remote string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(method, path, nil)
|
||||
req.RemoteAddr = remote
|
||||
rec := httptest.NewRecorder()
|
||||
e.h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
208
internal/api/integration_test.go
Normal file
@@ -0,0 +1,208 @@
|
||||
//go:build integration && linux
|
||||
|
||||
package api_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/v10networks/nmon/internal/api"
|
||||
"github.com/v10networks/nmon/internal/clock"
|
||||
"github.com/v10networks/nmon/internal/metrics"
|
||||
"github.com/v10networks/nmon/internal/store"
|
||||
"github.com/v10networks/nmon/internal/testutil"
|
||||
"github.com/v10networks/nmon/internal/ulid"
|
||||
"github.com/v10networks/nmon/internal/vmsup"
|
||||
)
|
||||
|
||||
// TestIntegrationGraphFromRealStores writes synthetic octet counters whose rate
|
||||
// rises, and hourly rollups for the weeks before, into real hot and long
|
||||
// stores, then asks the API for a one-hour graph and a stitched 40-day graph.
|
||||
func TestIntegrationGraphFromRealStores(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()) })
|
||||
|
||||
clk := clock.Real()
|
||||
st := testutil.OpenStore(t, clk)
|
||||
dev := &store.Device{Name: "Core", Kind: store.KindNetwork, Enabled: true, Profile: "generic"}
|
||||
if err := st.CreateDevice(ctx, dev); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ifc := store.Interface{ID: ulid.New(clk.Now()), DeviceID: dev.ID, IfIndex: 1, IfName: "xe-0/0/1", IfSpeed: 1e9, IfHighSpeed: 1000,
|
||||
AdminStatus: 1, OperStatus: 1, HasHCCounters: true}
|
||||
if err := st.UpsertInterfaces(ctx, dev.ID, []store.Interface{ifc}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// 3 h of one-minute samples ending 2 min ago. The in rate climbs from 1 to 4 Mbit/s; out is a flat 500 kbit/s.
|
||||
end := time.Now().Add(-2 * time.Minute).Truncate(time.Minute)
|
||||
const minutes = 3 * 60
|
||||
var hot []metrics.Sample
|
||||
var in, out float64
|
||||
for i := 0; i <= minutes; i++ {
|
||||
ts := end.Add(-time.Duration(minutes-i) * time.Minute)
|
||||
inBps := 1e6 + 3e6*float64(i)/minutes
|
||||
in += inBps / 8 * 60
|
||||
out += 500e3 / 8 * 60
|
||||
hot = append(hot,
|
||||
metrics.New("ifHCInOctets", in, ts, "device", dev.Slug, "iface", ifc.ID),
|
||||
metrics.New("ifHCOutOctets", out, ts, "device", dev.Slug, "iface", ifc.ID))
|
||||
}
|
||||
// Hourly rollups for 40 days back to 31 days ago, 800 kbit/s in and 200 out.
|
||||
var long []metrics.Sample
|
||||
for h := 40 * 24; h >= 31*24; h-- {
|
||||
ts := time.Now().Add(-time.Duration(h) * time.Hour).Truncate(time.Hour)
|
||||
long = append(long,
|
||||
metrics.New("nmon_if_rate_bps_avg_1h", 800e3, ts, "device", dev.Slug, "iface", ifc.ID, "dir", "in", "v", "1"),
|
||||
metrics.New("nmon_if_rate_bps_avg_1h", 200e3, ts, "device", dev.Slug, "iface", ifc.ID, "dir", "out", "v", "1"))
|
||||
}
|
||||
for name, ss := range map[string][]metrics.Sample{vmsup.StoreHot: hot, vmsup.StoreLong: long} {
|
||||
q := vmsup.NewQueue(name, sup, vmsup.QueueOptions{MaxAge: 100 * 24 * time.Hour})
|
||||
if err := q.Write(ctx, ss); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := q.Flush(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
h := api.New(api.Deps{Store: st, Querier: vmsup.NewQuerier(sup), Poller: noRefresh{}, Clock: clk})
|
||||
get := func(q url.Values) api.GraphResponse {
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/graph?"+q.Encode(), nil))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("graph: %d %s", rec.Code, rec.Body)
|
||||
}
|
||||
var g api.GraphResponse
|
||||
if err := json.Unmarshal(rec.Body.Bytes(), &g); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return g
|
||||
}
|
||||
// The store needs a moment to make flushed samples searchable.
|
||||
var g api.GraphResponse
|
||||
deadline := time.Now().Add(30 * time.Second)
|
||||
for {
|
||||
now := time.Now()
|
||||
g = get(url.Values{"iface": {ifc.ID}, "from": {now.Add(-time.Hour).UTC().Format(time.RFC3339)}, "to": {now.UTC().Format(time.RFC3339)},
|
||||
"max": {"true"}, "p95": {"true"}})
|
||||
if countValues(g.Series.In) > 30 || time.Now().After(deadline) {
|
||||
break
|
||||
}
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
if g.Tier != "raw" || g.StitchedAt != nil || g.StepSeconds != 60 {
|
||||
t.Fatalf("1h graph: tier=%s step=%d stitched=%v", g.Tier, g.StepSeconds, g.StitchedAt)
|
||||
}
|
||||
first, last := firstLast(g.Series.In)
|
||||
if first == nil || last == nil || *last <= *first*1.1 || *first < 2.5e6 || *last > 4.2e6 {
|
||||
t.Errorf("in series should rise from about 2.5 to 4 Mbit/s: first %v last %v", deref(first), deref(last))
|
||||
}
|
||||
if f, l := firstLast(g.Series.Out); f == nil || *f < 450e3 || *f > 550e3 || *l < 450e3 || *l > 550e3 {
|
||||
t.Errorf("out series should be a flat 500 kbit/s: %v %v", deref(f), deref(l))
|
||||
}
|
||||
if countValues(g.Series.InMax) < 30 {
|
||||
t.Errorf("max series: %d values", countValues(g.Series.InMax))
|
||||
}
|
||||
if g.P95 == nil || g.P95.InBPS == nil || *g.P95.InBPS < 2.5e6 || g.P95.Source != "live" || g.P95.Coverage <= 0 {
|
||||
t.Errorf("p95: %+v", g.P95)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
g = get(url.Values{"iface": {ifc.ID}, "from": {now.Add(-40 * 24 * time.Hour).UTC().Format(time.RFC3339)}, "to": {now.UTC().Format(time.RFC3339)}})
|
||||
if g.StitchedAt == nil || len(g.Tiers) != 2 || g.Tiers[0].Tier != "1h" {
|
||||
t.Fatalf("40d graph should be stitched: %+v", g.Tiers)
|
||||
}
|
||||
var fromLong int
|
||||
for _, p := range g.Series.In {
|
||||
if p.TS < g.StitchedAt.Unix() && p.V != nil {
|
||||
fromLong++
|
||||
if *p.V != 800e3 {
|
||||
t.Fatalf("long sample %d = %v, want 800000", p.TS, *p.V)
|
||||
}
|
||||
}
|
||||
}
|
||||
if fromLong < 150 {
|
||||
t.Errorf("only %d hourly samples came from the long store", fromLong)
|
||||
}
|
||||
if len(g.Partial) == 0 {
|
||||
t.Error("hot has 3 h of data in a 30-day window: the rest is partial")
|
||||
}
|
||||
|
||||
// A year needs a coarser step: the store must accept the averaging subquery and average equal hours to the same value.
|
||||
g = get(url.Values{"iface": {ifc.ID}, "from": {now.Add(-365 * 24 * time.Hour).UTC().Format(time.RFC3339)}, "to": {now.UTC().Format(time.RFC3339)}})
|
||||
if g.StitchedAt == nil || g.Tiers[0].StepSeconds <= 3600 {
|
||||
t.Fatalf("1y tiers: %+v", g.Tiers)
|
||||
}
|
||||
var coarse int
|
||||
for _, p := range g.Series.In {
|
||||
if p.TS < g.StitchedAt.Unix() && p.V != nil {
|
||||
coarse++
|
||||
if *p.V < 799e3 || *p.V > 801e3 {
|
||||
t.Errorf("coarse long sample %d = %v, want 800000", p.TS, *p.V)
|
||||
}
|
||||
}
|
||||
}
|
||||
if coarse == 0 {
|
||||
t.Error("the 1y graph has no long-store samples")
|
||||
}
|
||||
}
|
||||
|
||||
type noRefresh struct{}
|
||||
|
||||
func (noRefresh) Refresh() {}
|
||||
|
||||
func countValues(ps []api.Point) int {
|
||||
n := 0
|
||||
for _, p := range ps {
|
||||
if p.V != nil {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func firstLast(ps []api.Point) (first, last *float64) {
|
||||
for _, p := range ps {
|
||||
if p.V == nil {
|
||||
continue
|
||||
}
|
||||
if first == nil {
|
||||
first = p.V
|
||||
}
|
||||
last = p.V
|
||||
}
|
||||
return first, last
|
||||
}
|
||||
|
||||
func deref(p *float64) float64 {
|
||||
if p == nil {
|
||||
return -1
|
||||
}
|
||||
return *p
|
||||
}
|
||||