diff --git a/src/go/cmd/godplugin/config.go b/src/go/cmd/godplugin/config.go index 800d079f76..8d23cc4251 100644 --- a/src/go/cmd/godplugin/config.go +++ b/src/go/cmd/godplugin/config.go @@ -62,7 +62,7 @@ type config struct { collectorsDir multipath.MultiPath collectorsWatchPath []string serviceDiscoveryDir multipath.MultiPath - stateFile string + varLibDir string } func newConfig(opts *cli.Option, env *envConfig) *config { @@ -74,7 +74,7 @@ func newConfig(opts *cli.Option, env *envConfig) *config { cfg.collectorsDir = cfg.initCollectorsDir(opts) cfg.collectorsWatchPath = cfg.initCollectorsWatchPaths(opts, env) cfg.serviceDiscoveryDir = cfg.initServiceDiscoveryConfigDir() - cfg.stateFile = cfg.initStateFile(env) + cfg.varLibDir = env.varLibDir return cfg } @@ -156,13 +156,6 @@ func (c *config) initCollectorsWatchPaths(opts *cli.Option, env *envConfig) []st return append(opts.WatchPath, env.watchPath) } -func (c *config) initStateFile(env *envConfig) string { - if env.varLibDir == "" { - return "" - } - return filepath.Join(env.varLibDir, "god-jobs-statuses.json") -} - func (c *config) mustPluginDir() { if len(c.pluginDir) == 0 { panic("plugin config init: plugin dir is empty") diff --git a/src/go/cmd/godplugin/main.go b/src/go/cmd/godplugin/main.go index 191a7ce0fe..ab0ca87a86 100644 --- a/src/go/cmd/godplugin/main.go +++ b/src/go/cmd/godplugin/main.go @@ -53,7 +53,7 @@ func main() { CollectorsConfigDir: cfg.collectorsDir, ServiceDiscoveryConfigDir: cfg.serviceDiscoveryDir, CollectorsConfigWatchPath: cfg.collectorsWatchPath, - StateFile: cfg.stateFile, + VarLibDir: cfg.varLibDir, RunModule: opts.Module, MinUpdateEvery: opts.UpdateEvery, }) diff --git a/src/go/go.mod b/src/go/go.mod index 8a66914797..f426c4388c 100644 --- a/src/go/go.mod +++ b/src/go/go.mod @@ -44,6 +44,7 @@ require ( github.com/prometheus/prometheus v2.55.1+incompatible github.com/redis/go-redis/v9 v9.7.1 github.com/sijms/go-ora/v2 v2.8.24 + github.com/sourcegraph/conc v0.3.0 github.com/stretchr/testify v1.10.0 github.com/tidwall/gjson v1.18.0 github.com/valyala/fastjson v1.6.4 @@ -144,6 +145,7 @@ require ( go.opentelemetry.io/otel v1.34.0 // indirect go.opentelemetry.io/otel/metric v1.34.0 // indirect go.opentelemetry.io/otel/trace v1.34.0 // indirect + go.uber.org/multierr v1.11.0 // indirect golang.org/x/crypto v0.33.0 // indirect golang.org/x/mod v0.22.0 // indirect golang.org/x/oauth2 v0.25.0 // indirect diff --git a/src/go/go.sum b/src/go/go.sum index 3d9599ac2d..1883b9195c 100644 --- a/src/go/go.sum +++ b/src/go/go.sum @@ -409,6 +409,8 @@ github.com/sirupsen/logrus v1.4.1/go.mod h1:ni0Sbl8bgC9z8RoU9G6nDWqqs/fq4eDPysMB github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo= +github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0= github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= @@ -488,6 +490,8 @@ go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0= go.uber.org/multierr v1.3.0/go.mod h1:VgVr7evmIr6uPjLBxg28wmKNXyqE9akIJ5XnfpiKl+4= go.uber.org/multierr v1.5.0/go.mod h1:FeouvMocqHpRaaGuG9EjoKcStLC43Zu/fmqdUMPcKYU= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/tools v0.0.0-20190618225709-2cfd321de3ee/go.mod h1:vJERXedbb3MVM5f9Ejo0C68/HhF8uaILCdgjnY+goOA= go.uber.org/zap v1.9.1/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= go.uber.org/zap v1.10.0/go.mod h1:vwi/ZaCAaUcBkycHslxD9B2zi4UTXhF60s6SWpuDF0Q= diff --git a/src/go/plugin/go.d/agent/agent.go b/src/go/plugin/go.d/agent/agent.go index 967edb6be3..79d0b1577d 100644 --- a/src/go/plugin/go.d/agent/agent.go +++ b/src/go/plugin/go.d/agent/agent.go @@ -34,7 +34,7 @@ type Config struct { CollectorsConfigDir []string CollectorsConfigWatchPath []string ServiceDiscoveryConfigDir []string - StateFile string + VarLibDir string ModuleRegistry module.Registry RunModule string MinUpdateEvery int @@ -51,7 +51,7 @@ type Agent struct { CollectorsConfigWatchPath []string ServiceDiscoveryConfigDir multipath.MultiPath - StateFile string + VarLibDir string RunModule string MinUpdateEvery int @@ -75,13 +75,13 @@ func New(cfg Config) *Agent { CollectorsConfDir: cfg.CollectorsConfigDir, ServiceDiscoveryConfigDir: cfg.ServiceDiscoveryConfigDir, CollectorsConfigWatchPath: cfg.CollectorsConfigWatchPath, - StateFile: cfg.StateFile, + VarLibDir: cfg.VarLibDir, RunModule: cfg.RunModule, MinUpdateEvery: cfg.MinUpdateEvery, ModuleRegistry: module.DefaultRegistry, Out: safewriter.Stdout, api: netdataapi.New(safewriter.Stdout), - quitCh: make(chan struct{}), + quitCh: make(chan struct{}, 1), } } @@ -194,7 +194,7 @@ func (a *Agent) run(ctx context.Context) { jobMgr := jobmgr.New() jobMgr.PluginName = a.Name jobMgr.Out = a.Out - jobMgr.StateFile = a.StateFile + jobMgr.VarLibDir = a.VarLibDir jobMgr.Modules = enabledModules jobMgr.ConfigDefaults = discCfg.Registry jobMgr.FnReg = fnMgr diff --git a/src/go/plugin/go.d/agent/discovery/manager.go b/src/go/plugin/go.d/agent/discovery/manager.go index 6466160236..b9b928f3fb 100644 --- a/src/go/plugin/go.d/agent/discovery/manager.go +++ b/src/go/plugin/go.d/agent/discovery/manager.go @@ -119,12 +119,18 @@ func (m *Manager) registerDiscoverers(cfg Config) error { } func (m *Manager) runDiscoverer(ctx context.Context, d discoverer) { + done := make(chan struct{}) updates := make(chan []*confgroup.Group) - go d.Run(ctx, updates) + + go func() { defer close(done); d.Run(ctx, updates) }() for { select { case <-ctx.Done(): + select { + case <-done: + case <-time.After(time.Second * 10): + } return case groups, ok := <-updates: if !ok { diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go index 2cf0575b57..58c0812382 100644 --- a/src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/dockerd/target.go @@ -18,7 +18,7 @@ func (g *targetGroup) Source() string { return g.source } func (g *targetGroup) Targets() []model.Target { return g.targets } type target struct { - model.Base + model.Base `hash:"ignore"` hash uint64 diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go index 9d57d3cc70..c950fdb28e 100644 --- a/src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/netlisteners/target.go @@ -20,7 +20,7 @@ func (g *targetGroup) Source() string { return g.source } func (g *targetGroup) Targets() []model.Target { return g.targets } type target struct { - model.Base + model.Base `hash:"ignore"` hash uint64 diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/config.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/config.go new file mode 100644 index 0000000000..7e5c009467 --- /dev/null +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/config.go @@ -0,0 +1,210 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmpsd + +import ( + "fmt" + + "github.com/gosnmp/gosnmp" + + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/confopt" + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange" +) + +type ( + Config struct { + // RescanInterval defines how often to scan the networks for devices (default: 30m) + RescanInterval confopt.Duration `yaml:"rescan_interval"` + // Timeout defines the maximum time to wait for SNMP device responses (default: 1s) + Timeout confopt.Duration `yaml:"timeout"` + // DeviceCacheTTL defines how long to trust cached discovery results before requiring a new probe (default: 12h) + DeviceCacheTTL confopt.Duration `yaml:"device_cache_ttl"` + // ParallelScansPerNetwork defines how many IPs to scan concurrently within each subnet (default: 32) + ParallelScansPerNetwork int `yaml:"parallel_scans_per_network"` + // Credentials define the SNMP credentials used for authentication + Credentials []CredentialConfig `yaml:"credentials"` + // Networks defines the subnets to scan and which credentials to use + Networks []NetworkConfig `yaml:"networks"` + } + + NetworkConfig struct { + // Subnet is the IP range to scan, supporting various formats + // https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats + Subnet string `yaml:"subnet"` + // Credential is the name of a credential from the Credentials list + Credential string `yaml:"credential"` + } + CredentialConfig struct { + // Name is the identifier for this credential set, used in Network.Credential + Name string `yaml:"name"` + // Version must be one of: "1", "2c", or "3" + Version string `yaml:"version"` + // Community is the SNMP community string (used in v1 and v2c) + Community string `yaml:"community"` + // UserName is the SNMPv3 username + UserName string `yaml:"username"` + // SecurityLevel must be one of: "noAuthNoPriv", "authNoPriv", or "authPriv" (for SNMPv3) + SecurityLevel string `yaml:"security_level"` + // AuthProtocol must be one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512" (for SNMPv3) + AuthProtocol string `yaml:"auth_protocol"` + // AuthPassphrase is the authentication passphrase (for SNMPv3) + AuthPassphrase string `yaml:"auth_passphrase"` + // PrivacyProtocol must be one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C" (for SNMPv3) + PrivacyProtocol string `yaml:"privacy_protocol"` + // PrivacyPassphrase is the privacy passphrase (for SNMPv3) + PrivacyPassphrase string `yaml:"privacy_passphrase"` + } +) + +func (c *Config) validateAndParse() ([]subnet, error) { + if len(c.Credentials) == 0 { + return nil, fmt.Errorf("no credentials provided") + } + if len(c.Networks) == 0 { + return nil, fmt.Errorf("no networks provided") + } + + credentials := make(map[string]CredentialConfig) + + for i, cr := range c.Credentials { + if cr.Name == "" { + return nil, fmt.Errorf("no name provided for credential %d", i) + } + if _, ok := credentials[cr.Name]; ok { + return nil, fmt.Errorf("duplicate credential name: %s", cr.Name) + } + credentials[cr.Name] = c.Credentials[i] + } + + networks := make(map[string]bool) + + var subnets []subnet + + for i, n := range c.Networks { + if n.Subnet == "" { + return nil, fmt.Errorf("no subnet provided for network %d", i) + } + if n.Credential == "" { + return nil, fmt.Errorf("no credential provided for network %s", n.Subnet) + } + if _, ok := credentials[n.Credential]; !ok { + return nil, fmt.Errorf("no credential provided for network %s", n.Subnet) + } + + r, err := iprange.ParseRange(n.Subnet) + if err != nil { + return nil, fmt.Errorf("invalid subnet range '%s': %v", n.Subnet, err) + } + + // Limit subnet size to /23 or smaller (512 IPs max per subnet) + // This prevents accidental scanning of excessively large networks. + if s := r.Size().Int64(); s > 512 { + return nil, fmt.Errorf("subnet '%s' exceeds maximum size of /23 (512 IPs, got %d IPs)", n.Subnet, s) + } + + sub := subnet{ + str: n.Subnet, + ips: r, + credential: credentials[n.Credential], + } + + if networks[subKey(sub)] { + return nil, fmt.Errorf("duplicate subnet '%s'", subKey(sub)) + } + networks[subKey(sub)] = true + + subnets = append(subnets, sub) + } + + return subnets, nil +} + +func setCredential(client gosnmp.Handler, cred CredentialConfig) { + switch parseSNMPVersion(cred) { + case gosnmp.Version1: + client.SetVersion(gosnmp.Version1) + client.SetCommunity(cred.Community) + case gosnmp.Version2c: + client.SetVersion(gosnmp.Version2c) + client.SetCommunity(cred.Community) + case gosnmp.Version3: + client.SetVersion(gosnmp.Version3) + client.SetSecurityModel(gosnmp.UserSecurityModel) + client.SetMsgFlags(parseSNMPv3SecurityLevel(cred)) + client.SetSecurityParameters(&gosnmp.UsmSecurityParameters{ + UserName: cred.UserName, + AuthenticationProtocol: parseSNMPv3AuthProtocol(cred), + AuthenticationPassphrase: cred.AuthPassphrase, + PrivacyProtocol: parseSNMPv3PrivProtocol(cred), + PrivacyPassphrase: cred.PrivacyPassphrase, + }) + } +} + +func parseSNMPVersion(cred CredentialConfig) gosnmp.SnmpVersion { + switch cred.Version { + case "0", "1": + return gosnmp.Version1 + case "2", "2c", "": + return gosnmp.Version2c + case "3": + return gosnmp.Version3 + default: + return gosnmp.Version2c + } +} + +func parseSNMPv3SecurityLevel(cred CredentialConfig) gosnmp.SnmpV3MsgFlags { + switch cred.SecurityLevel { + case "1", "none", "noAuthNoPriv", "": + return gosnmp.NoAuthNoPriv + case "2", "authNoPriv": + return gosnmp.AuthNoPriv + case "3", "authPriv": + return gosnmp.AuthPriv + default: + return gosnmp.NoAuthNoPriv + } +} + +func parseSNMPv3AuthProtocol(cred CredentialConfig) gosnmp.SnmpV3AuthProtocol { + switch cred.AuthProtocol { + case "1", "none", "noAuth", "": + return gosnmp.NoAuth + case "2", "md5", "MD5": + return gosnmp.MD5 + case "3", "sha", "SHA": + return gosnmp.SHA + case "4", "sha224", "SHA224": + return gosnmp.SHA224 + case "5", "sha256", "SHA256": + return gosnmp.SHA256 + case "6", "sha384", "SHA384": + return gosnmp.SHA384 + case "7", "sha512", "SHA512": + return gosnmp.SHA512 + default: + return gosnmp.NoAuth + } +} + +func parseSNMPv3PrivProtocol(cred CredentialConfig) gosnmp.SnmpV3PrivProtocol { + switch cred.PrivacyProtocol { + case "1", "none", "noPriv", "": + return gosnmp.NoPriv + case "2", "des", "DES": + return gosnmp.DES + case "3", "aes", "AES": + return gosnmp.AES + case "4", "aes192", "AES192": + return gosnmp.AES192 + case "5", "aes256", "AES256": + return gosnmp.AES256 + case "6", "aes192c", "AES192C": + return gosnmp.AES192C + case "7", "aes256c", "AES256C": + return gosnmp.AES256C + default: + return gosnmp.NoPriv + } +} diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer.go new file mode 100644 index 0000000000..6853675f4b --- /dev/null +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer.go @@ -0,0 +1,266 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmpsd + +import ( + "context" + "fmt" + "log/slog" + "sync/atomic" + "time" + + "github.com/gohugoio/hashstructure" + "github.com/gosnmp/gosnmp" + "github.com/sourcegraph/conc/pool" + + "github.com/netdata/netdata/go/plugins/logger" + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model" + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister" + "github.com/netdata/netdata/go/plugins/plugin/go.d/pkg/iprange" +) + +const ( + defaultRescanInterval = time.Minute * 30 + defaultTimeout = time.Second * 1 + defaultParallelScansPerNetwork = 32 + defaultDeviceCacheTTL = time.Hour * 12 +) + +func NewDiscoverer(cfg Config) (*Discoverer, error) { + subnets, err := cfg.validateAndParse() + if err != nil { + return nil, err + } + + cfgHash, _ := hashstructure.Hash(cfg, nil) + + d := &Discoverer{ + Logger: logger.New().With( + slog.String("component", "service discovery"), + slog.String("discoverer", "snmp"), + ), + started: make(chan struct{}), + cfgHash: cfgHash, + subnets: subnets, + newSnmpClient: func() (gosnmp.Handler, func()) { + return gosnmp.NewHandler(), func() {} + }, + + rescanInterval: defaultRescanInterval, + timeout: defaultTimeout, + parallelScansPerNetwork: defaultParallelScansPerNetwork, + deviceCacheTTL: defaultDeviceCacheTTL, + + firstDiscovery: true, + status: newDiscoveryStatus(), + } + + if cfg.RescanInterval > 0 { + d.rescanInterval = cfg.RescanInterval.Duration() + } + if cfg.Timeout > 0 { + d.timeout = cfg.Timeout.Duration() + } + if cfg.ParallelScansPerNetwork > 0 { + d.parallelScansPerNetwork = cfg.ParallelScansPerNetwork + } + if cfg.DeviceCacheTTL > 0 { + d.deviceCacheTTL = cfg.DeviceCacheTTL.Duration() + } + + return d, nil +} + +type ( + Discoverer struct { + *logger.Logger + model.Base + + started chan struct{} + cfgHash uint64 + + subnets []subnet + + newSnmpClient func() (gosnmp.Handler, func()) + + parallelScansPerNetwork int + rescanInterval time.Duration + timeout time.Duration + deviceCacheTTL time.Duration + + firstDiscovery bool + status *discoveryStatus + statusUpdated atomic.Bool + } + subnet struct { + str string + ips iprange.Range + credential CredentialConfig + } +) + +func (d *Discoverer) String() string { + return "sd:snmp" +} + +func (d *Discoverer) Discover(ctx context.Context, in chan<- []model.TargetGroup) { + d.Info("instance is started") + defer func() { d.Info("instance is stopped") }() + + close(d.started) + + d.loadFileStatus() + + d.discoverNetworks(ctx, in) + + if d.rescanInterval <= 0 { + filepersister.Save(statusFileName(), d.status) + return + } + + tk := time.NewTicker(d.rescanInterval) + defer tk.Stop() + + for { + select { + case <-ctx.Done(): + return + case <-tk.C: + d.discoverNetworks(ctx, in) + } + } +} + +func (d *Discoverer) discoverNetworks(ctx context.Context, in chan<- []model.TargetGroup) { + now := time.Now() + + doProbing := !d.firstDiscovery || + d.status.ConfigHash != d.cfgHash || + now.After(d.status.LastDiscoveryTime.Add(d.rescanInterval)) + + defer func() { + if isDone(ctx) { + return + } + d.firstDiscovery = false + + if doProbing { + d.status.LastDiscoveryTime = now + } + + if d.statusUpdated.Swap(false) || d.status.ConfigHash != d.cfgHash { + d.status.ConfigHash = d.cfgHash + filepersister.Save(statusFileName(), d.status) + } + }() + + d.Infof("discovery mode: %s", map[bool]string{true: "active probing", false: "using cache"}[doProbing]) + + p := pool.New() + for _, sub := range d.subnets { + sub := sub + p.Go(func() { d.discoverNetwork(ctx, in, sub, doProbing) }) + } + p.Wait() +} + +func (d *Discoverer) discoverNetwork(ctx context.Context, in chan<- []model.TargetGroup, sub subnet, doProbing bool) { + tgg := newTargetGroup(sub) + p := pool.New().WithMaxGoroutines(d.parallelScansPerNetwork) + + for ip := range sub.ips.Iterate() { + ipAddr := ip.String() + + if doProbing { + p.Go(func() { d.probeIPAddress(ctx, sub, ipAddr, tgg) }) + } else { + d.useCacheIPAddress(sub, ipAddr, tgg) + } + } + p.Wait() + + send(ctx, in, tgg) +} + +func (d *Discoverer) useCacheIPAddress(sub subnet, ip string, tgg *targetGroup) { + if dev := d.status.get(sub, ip); dev != nil { + tg := newTarget(ip, sub.credential, dev.SysInfo) + tgg.addTarget(tg) + } +} + +func (d *Discoverer) probeIPAddress(ctx context.Context, sub subnet, ip string, tgg *targetGroup) { + if isDone(ctx) { + return + } + + now := time.Now() + + dev := d.status.get(sub, ip) + + // Use the cached device if available and not expired + if dev != nil && now.Before(dev.DiscoverTime.Add(d.deviceCacheTTL)) { + if d.firstDiscovery { + untilProbe := dev.DiscoverTime.Add(d.deviceCacheTTL).Sub(now).Round(time.Second) + d.Infof("device '%s': found in cache (sysName: '%s', network: '%s', next probe in %s)", + ip, dev.SysInfo.Name, subKey(sub), untilProbe) + } + tg := newTarget(ip, sub.credential, dev.SysInfo) + tgg.addTarget(tg) + return + } + + si, err := d.getSnmpSysInfo(sub, ip) + if err != nil { + if dev == nil { + // First-time discovery failure - log at debug level as this is expected for many IPs + d.Debugf("device '%s': probe failed (network: '%s'): %v", ip, subKey(sub), err) + } else { + // Previously discovered device is now unreachable + d.Warningf("lost connection to previously discovered SNMP device '%s' (sysName: '%s', network: '%s'): %v", + ip, dev.SysInfo.Name, subKey(sub), err) + } + d.status.del(sub, ip) + d.statusUpdated.Store(dev != nil) + return + } + + d.Infof("device '%s': successfully discovered (sysName: '%s', network: '%s')", ip, si.Name, subKey(sub)) + d.status.put(sub, ip, &discoveredDevice{DiscoverTime: now, SysInfo: *si}) + d.statusUpdated.Store(true) + tg := newTarget(ip, sub.credential, *si) + tgg.addTarget(tg) +} + +func (d *Discoverer) getSnmpSysInfo(sub subnet, ip string) (*SysInfo, error) { + client, cleanup := d.newSnmpClient() + defer cleanup() + + client.SetTarget(ip) + client.SetTimeout(d.timeout) + client.SetRetries(0) + setCredential(client, sub.credential) + + if err := client.Connect(); err != nil { + return nil, fmt.Errorf("failed to connect: %v", err) + } + + defer func() { _ = client.Close() }() + + return GetSysInfo(client) +} + +func send(ctx context.Context, in chan<- []model.TargetGroup, tgg model.TargetGroup) { + select { + case <-ctx.Done(): + case in <- []model.TargetGroup{tgg}: + } +} +func isDone(ctx context.Context) bool { + select { + case <-ctx.Done(): + return true + default: + return false + } +} diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer_test.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer_test.go new file mode 100644 index 0000000000..14c42ed863 --- /dev/null +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/discoverer_test.go @@ -0,0 +1,279 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmpsd + +import ( + "strconv" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model" +) + +func TestNewDiscoverer(t *testing.T) { + tests := map[string]struct { + cfg Config + wantFail bool + }{ + "succeeds with valid SNMPv1 config": { + wantFail: false, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "v1cred", Version: "1", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "v1cred"}, + }, + }, + }, + "succeeds with valid SNMPv2c config": { + wantFail: false, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "v2cred", Version: "2c", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "v2cred"}, + }, + }, + }, + "succeeds with valid SNMPv3 config": { + wantFail: false, + cfg: Config{ + Credentials: []CredentialConfig{ + { + Name: "v3cred", + Version: "3", + UserName: "user", + SecurityLevel: "authPriv", + AuthProtocol: "sha", + AuthPassphrase: "authpass", + PrivacyProtocol: "aes", + PrivacyPassphrase: "privpass", + }, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "v3cred"}, + }, + }, + }, + "succeeds with multiple valid credentials and networks": { + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "v1cred", Version: "1", Community: "public"}, + {Name: "v2cred", Version: "2c", Community: "private"}, + { + Name: "v3cred", + Version: "3", + UserName: "user", + SecurityLevel: "authPriv", + AuthProtocol: "sha", + AuthPassphrase: "authpass", + PrivacyProtocol: "aes", + PrivacyPassphrase: "privpass", + }, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "v1cred"}, + {Subnet: "10.0.0.0/24", Credential: "v2cred"}, + {Subnet: "172.16.0.0/24", Credential: "v3cred"}, + }, + }, + wantFail: false, + }, + "fails on empty config": { + wantFail: true, + }, + "fails with credentials but no networks": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "test", Version: "2c", Community: "public"}, + }, + }, + }, + "fails with networks but no credentials": { + wantFail: true, + cfg: Config{ + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "test"}, + }, + }, + }, + "fails with credential without name": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Version: "2c", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "test"}, + }, + }, + }, + "fails with duplicate credential names": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "test", Version: "2c", Community: "public"}, + {Name: "test", Version: "2c", Community: "private"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "test"}, + }, + }, + }, + "fails with network without subnet": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "test", Version: "2c", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Credential: "test"}, + }, + }, + }, + "fails with network without credential": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "test", Version: "2c", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24"}, + }, + }, + }, + "fails with network with nonexistent credential": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "test", Version: "2c", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "nonexistent"}, + }, + }, + }, + "fails with invalid subnet format": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "test", Version: "2c", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Subnet: "invalid-subnet", Credential: "test"}, + }, + }, + }, + "fails with subnet too large (> 512 IPs)": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "test", Version: "2c", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/22", Credential: "test"}, // 1024 IPs + }, + }, + }, + "fails with duplicate subnet": { + wantFail: true, + cfg: Config{ + Credentials: []CredentialConfig{ + {Name: "test", Version: "2c", Community: "public"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/24", Credential: "test"}, + {Subnet: "192.0.2.0/24", Credential: "test"}, + }, + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + d, err := NewDiscoverer(test.cfg) + + if test.wantFail { + assert.Error(t, err) + } else { + assert.NoError(t, err) + assert.NotNil(t, d) + } + }) + } +} + +func TestDiscoverer_Run(t *testing.T) { + tests := map[string]struct { + prepareSim func(t *testing.T) *discoverySim + }{ + "simple discovery": { + prepareSim: func(t *testing.T) *discoverySim { + cfg := Config{ + Credentials: []CredentialConfig{ + {Name: "public-v2", Version: "2", Community: "public-v2"}, + }, + Networks: []NetworkConfig{ + {Subnet: "192.0.2.0/29", Credential: "public-v2"}, + }, + } + + subnets, err := cfg.validateAndParse() + sub := subnets[0] + require.NoError(t, err) + + sim := discoverySim{ + cfg: cfg, + updateSnmpHandler: func(m *mockSnmpHandler) { + m.skipOnConnect = func(ip string) bool { + // Skip if the last octet is odd + i := strings.LastIndexByte(ip, '.') + if i == -1 { + return false + } + lastOctet, err := strconv.Atoi(ip[i+1:]) + return err == nil && lastOctet%2 != 0 + } + }, + wantGroups: []model.TargetGroup{ + prepareNewTargetGroup(sub, "192.0.2.2", "192.0.2.4", "192.0.2.6"), + }, + } + + return &sim + }, + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + sim := test.prepareSim(t) + sim.run(t) + }) + } +} + +func prepareNewTargetGroup(sub subnet, ips ...string) *targetGroup { + tgg := newTargetGroup(sub) + for _, ip := range ips { + tg := prepareNewTarget(sub, ip) + tgg.addTarget(tg) + } + return tgg +} + +func prepareNewTarget(sub subnet, ip string) *target { + return newTarget(ip, sub.credential, SysInfo{ + Descr: mockSysDescr, + Contact: mockSysContact, + Name: mockSysName, + Location: mockSysLocation, + Organization: "net-snmp", + }) +} diff --git a/src/go/plugin/go.d/collector/snmp/entnum/enterprise-numbers.txt b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/enterprise-numbers.txt similarity index 100% rename from src/go/plugin/go.d/collector/snmp/entnum/enterprise-numbers.txt rename to src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/enterprise-numbers.txt diff --git a/src/go/plugin/go.d/collector/snmp/entnum/lookup.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/entnum.go similarity index 99% rename from src/go/plugin/go.d/collector/snmp/entnum/lookup.go rename to src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/entnum.go index bcbfa9d7fa..336f98d413 100644 --- a/src/go/plugin/go.d/collector/snmp/entnum/lookup.go +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/entnum.go @@ -1,6 +1,6 @@ // SPDX-License-Identifier: GPL-3.0-or-later -package entnum +package snmpsd import ( "bufio" diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sim_test.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sim_test.go new file mode 100644 index 0000000000..5a4ec34b2e --- /dev/null +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sim_test.go @@ -0,0 +1,183 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmpsd + +import ( + "context" + "errors" + "sort" + "sync" + "testing" + "time" + + "github.com/golang/mock/gomock" + "github.com/gosnmp/gosnmp" + snmpmock "github.com/gosnmp/gosnmp/mocks" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model" +) + +type discoverySim struct { + cfg Config + updateSnmpHandler func(m *mockSnmpHandler) + wantGroups []model.TargetGroup +} + +func (sim *discoverySim) run(t *testing.T) { + d, err := NewDiscoverer(sim.cfg) + require.NoError(t, err) + + d.newSnmpClient = func() (gosnmp.Handler, func()) { + h, cleanup := prepareMockSnmpHandler(t) + h.setExpectInit() + h.setExpectSysInfo() + if sim.updateSnmpHandler != nil { + sim.updateSnmpHandler(h) + } + return h, cleanup + } + + seen := make(map[string]model.TargetGroup) + ctx, cancel := context.WithCancel(context.Background()) + in := make(chan []model.TargetGroup) + var wg sync.WaitGroup + + wg.Add(1) + go func() { + defer wg.Done() + d.Discover(ctx, in) + }() + + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + case tggs := <-in: + for _, tgg := range tggs { + seen[tgg.Source()] = tgg + } + } + } + }() + + done := make(chan struct{}) + go func() { + defer close(done) + wg.Wait() + }() + + select { + case <-d.started: + case <-time.After(time.Second * 5): + require.Fail(t, "discovery failed to start") + } + + time.Sleep(time.Second * 2) + + cancel() + + select { + case <-done: + case <-time.After(time.Second * 5): + require.Fail(t, "discovery hasn't finished after cancel") + } + + var tggs []model.TargetGroup + for _, tgg := range seen { + tggs = append(tggs, tgg) + } + + sortTargetGroups(tggs) + sortTargetGroups(sim.wantGroups) + + wantLen, gotLen := calcTargets(sim.wantGroups), calcTargets(tggs) + assert.Equalf(t, wantLen, gotLen, "different len (want %d got %d)", wantLen, gotLen) + assert.Equal(t, sim.wantGroups, tggs) +} + +func calcTargets(tggs []model.TargetGroup) int { + var n int + for _, tgg := range tggs { + n += len(tgg.Targets()) + } + return n +} + +func sortTargetGroups(tggs []model.TargetGroup) { + if len(tggs) == 0 { + return + } + sort.Slice(tggs, func(i, j int) bool { return tggs[i].Source() < tggs[j].Source() }) + + for idx := range tggs { + tgts := tggs[idx].Targets() + sort.Slice(tgts, func(i, j int) bool { return tgts[i].Hash() < tgts[j].Hash() }) + } +} + +type mockSnmpHandler struct { + mu sync.Mutex + *snmpmock.MockHandler + skipOnConnect func(ip string) bool +} + +func (m *mockSnmpHandler) Connect() error { + if m.skipOnConnect != nil && m.skipOnConnect(m.MockHandler.Target()) { + return errors.New("mock handler skip connect") + } + return m.MockHandler.Connect() +} + +func prepareMockSnmpHandler(t *testing.T) (*mockSnmpHandler, func()) { + mockCtl := gomock.NewController(t) + cleanup := func() { mockCtl.Finish() } + mockSNMP := snmpmock.NewMockHandler(mockCtl) + m := &mockSnmpHandler{MockHandler: mockSNMP} + + return m, cleanup +} + +func (m *mockSnmpHandler) setExpectInit() { + var ip string + m.EXPECT().Target().DoAndReturn(func() string { return ip }).AnyTimes() + m.EXPECT().SetTarget(gomock.Any()).Do(func(target string) { ip = target }).AnyTimes() + m.EXPECT().Port().AnyTimes() + m.EXPECT().Version().AnyTimes() + m.EXPECT().Community().AnyTimes() + m.EXPECT().SetPort(gomock.Any()).AnyTimes() + m.EXPECT().SetRetries(gomock.Any()).AnyTimes() + m.EXPECT().SetMaxRepetitions(gomock.Any()).AnyTimes() + m.EXPECT().SetMaxOids(gomock.Any()).AnyTimes() + m.EXPECT().SetLogger(gomock.Any()).AnyTimes() + m.EXPECT().SetTimeout(gomock.Any()).AnyTimes() + m.EXPECT().SetCommunity(gomock.Any()).AnyTimes() + m.EXPECT().SetVersion(gomock.Any()).AnyTimes() + m.EXPECT().SetSecurityModel(gomock.Any()).AnyTimes() + m.EXPECT().SetMsgFlags(gomock.Any()).AnyTimes() + m.EXPECT().SetSecurityParameters(gomock.Any()).AnyTimes() + m.EXPECT().Connect().Return(nil).AnyTimes() + m.EXPECT().Close().Return(nil).AnyTimes() +} + +const ( + mockSysDescr = "mock sysDescr" + mockSysObject = ".1.3.6.1.4.1.8072.3.2.10" + mockSysContact = "mock sysContact" + mockSysName = "mock sysName" + mockSysLocation = "mock sysLocation" +) + +func (m *mockSnmpHandler) setExpectSysInfo() { + m.EXPECT().WalkAll(RootOidMibSystem).Return([]gosnmp.SnmpPDU{ + {Name: OidSysDescr, Value: []uint8(mockSysDescr), Type: gosnmp.OctetString}, + {Name: OidSysObject, Value: mockSysObject, Type: gosnmp.ObjectIdentifier}, + {Name: OidSysContact, Value: []uint8(mockSysContact), Type: gosnmp.OctetString}, + {Name: OidSysName, Value: []uint8(mockSysName), Type: gosnmp.OctetString}, + {Name: OidSysLocation, Value: []uint8(mockSysLocation), Type: gosnmp.OctetString}, + }, nil).AnyTimes() +} diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/status.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/status.go new file mode 100644 index 0000000000..bda4a413d2 --- /dev/null +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/status.go @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmpsd + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "time" +) + +func (d *Discoverer) loadFileStatus() { + d.status = newDiscoveryStatus() + + filename := statusFileName() + if filename == "" { + return + } + + f, err := os.Open(filename) + if err != nil { + d.Warningf("failed to open status file %s: %v", filename, err) + return + } + defer func() { _ = f.Close() }() + + if err := json.NewDecoder(f).Decode(d.status); err != nil { + d.Warningf("failed to parse status file %s: %v", filename, err) + return + } + + d.Infof("loaded status file: last discovery=%s", d.status.LastDiscoveryTime) +} + +func statusFileName() string { + v := os.Getenv("NETDATA_LIB_DIR") + if v == "" { + return "" + } + return filepath.Join(v, "god-sd-snmp-status.json") +} + +func newDiscoveryStatus() *discoveryStatus { + return &discoveryStatus{ + Networks: make(map[string]map[string]*discoveredDevice), + } +} + +type ( + discoveryStatus struct { + mux sync.RWMutex + Networks map[string]map[string]*discoveredDevice `json:"networks"` + LastDiscoveryTime time.Time `json:"last_discovery_time"` + ConfigHash uint64 `json:"config_hash"` + } + discoveredDevice struct { + DiscoverTime time.Time `json:"discover_time"` + SysInfo SysInfo `json:"sysinfo"` + } +) + +func (s *discoveryStatus) Bytes() ([]byte, error) { + s.mux.RLock() + defer s.mux.RUnlock() + + return json.MarshalIndent(s, "", " ") +} + +func (s *discoveryStatus) get(sub subnet, ip string) *discoveredDevice { + s.mux.RLock() + defer s.mux.RUnlock() + + devices, ok := s.Networks[subKey(sub)] + if !ok { + return nil + } + return devices[ip] +} + +func (s *discoveryStatus) put(sub subnet, ip string, dev *discoveredDevice) { + s.mux.Lock() + defer s.mux.Unlock() + + devices, ok := s.Networks[subKey(sub)] + if !ok { + devices = make(map[string]*discoveredDevice) + s.Networks[subKey(sub)] = devices + } + devices[ip] = dev +} + +func (s *discoveryStatus) del(sub subnet, ip string) { + s.mux.Lock() + defer s.mux.Unlock() + + if devices, ok := s.Networks[subKey(sub)]; ok { + delete(devices, ip) + } +} + +func subKey(s subnet) string { + return fmt.Sprintf("%s:%s", s.str, s.credential.Name) +} diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sysinfo.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sysinfo.go new file mode 100644 index 0000000000..0917e72991 --- /dev/null +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/sysinfo.go @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmpsd + +import ( + "fmt" + "strings" + + "github.com/gosnmp/gosnmp" +) + +const ( + RootOidMibSystem = "1.3.6.1.2.1.1" + OidSysDescr = "1.3.6.1.2.1.1.1.0" + OidSysObject = "1.3.6.1.2.1.1.2.0" + OidSysUptime = "1.3.6.1.2.1.1.3.0" + OidSysContact = "1.3.6.1.2.1.1.4.0" + OidSysName = "1.3.6.1.2.1.1.5.0" + OidSysLocation = "1.3.6.1.2.1.1.6.0" +) + +type SysInfo struct { + Descr string `json:"description"` + Contact string `json:"contact"` + Name string `json:"name"` + Location string `json:"location"` + Organization string `json:"organization"` +} + +func GetSysInfo(client gosnmp.Handler) (*SysInfo, error) { + pdus, err := client.WalkAll(RootOidMibSystem) + if err != nil { + return nil, err + } + + si := &SysInfo{ + Name: "unknown", + Organization: "Unknown", + } + + r := strings.NewReplacer("\n", "\\n", "\r", "\\r") + + for _, pdu := range pdus { + oid := strings.TrimPrefix(pdu.Name, ".") + + switch oid { + case OidSysDescr: + if si.Descr, err = PduToString(pdu); err == nil { + si.Descr = r.Replace(si.Descr) + } + case OidSysObject: + var sysObj string + if sysObj, err = PduToString(pdu); err == nil { + si.Organization = LookupBySysObject(sysObj) + } + case OidSysContact: + si.Contact, err = PduToString(pdu) + case OidSysName: + si.Name, err = PduToString(pdu) + case OidSysLocation: + si.Location, err = PduToString(pdu) + } + if err != nil { + return nil, fmt.Errorf("OID '%s': %v", pdu.Name, err) + } + } + + return si, nil +} + +func PduToString(pdu gosnmp.SnmpPDU) (string, error) { + switch pdu.Type { + case gosnmp.OctetString: + // TODO: this isn't reliable (e.g. physAddress we need hex.EncodeToString()) + bs, ok := pdu.Value.([]byte) + if !ok { + return "", fmt.Errorf("OctetString is not a []byte but %T", pdu.Value) + } + return strings.ToValidUTF8(string(bs), "�"), nil + case gosnmp.Counter32, gosnmp.Counter64, gosnmp.Integer, gosnmp.Gauge32: + return gosnmp.ToBigInt(pdu.Value).String(), nil + case gosnmp.ObjectIdentifier: + v, ok := pdu.Value.(string) + if !ok { + return "", fmt.Errorf("ObjectIdentifier is not a string but %T", pdu.Value) + } + return strings.TrimPrefix(v, "."), nil + default: + return "", fmt.Errorf("unussported type: '%v'", pdu.Type) + } +} diff --git a/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/target.go b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/target.go new file mode 100644 index 0000000000..b095b2244f --- /dev/null +++ b/src/go/plugin/go.d/agent/discovery/sd/discoverer/snmpsd/target.go @@ -0,0 +1,64 @@ +// SPDX-License-Identifier: GPL-3.0-or-later + +package snmpsd + +import ( + "fmt" + "sync" + + "github.com/gohugoio/hashstructure" + + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model" +) + +func targetSource(sub subnet) string { return fmt.Sprintf("discoverer=snmp,network=%s", subKey(sub)) } + +func newTargetGroup(sub subnet) *targetGroup { + return &targetGroup{ + provider: "sd:snmpdiscoverer", + source: targetSource(sub), + } +} + +type targetGroup struct { + provider string + source string + mux sync.Mutex + targets []model.Target +} + +func (g *targetGroup) Provider() string { return g.provider } +func (g *targetGroup) Source() string { return g.source } +func (g *targetGroup) Targets() []model.Target { return g.targets } + +func (g *targetGroup) addTarget(tg model.Target) { + g.mux.Lock() + defer g.mux.Unlock() + g.targets = append(g.targets, tg) +} + +func newTarget(ip string, cred CredentialConfig, si SysInfo) *target { + tg := &target{ + IPAddress: ip, + Credential: cred, + SysInfo: si, + } + + tg.hash, _ = hashstructure.Hash(tg, nil) + + return tg +} + +type ( + target struct { + model.Base `hash:"ignore"` + hash uint64 + + IPAddress string + Credential CredentialConfig `hash:"ignore"` + SysInfo SysInfo `hash:"ignore"` + } +) + +func (t *target) TUID() string { return fmt.Sprintf("snmp_%s_%d", t.IPAddress, t.hash) } +func (t *target) Hash() uint64 { return t.hash } diff --git a/src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go b/src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go index 60c9014929..ab59fb8544 100644 --- a/src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go +++ b/src/go/plugin/go.d/agent/discovery/sd/pipeline/accumulator.go @@ -51,7 +51,7 @@ func (a *accumulator) run(ctx context.Context, in chan []model.TargetGroup) { select { case <-done: a.Info("all discoverers exited") - case <-time.After(time.Second * 3): + case <-time.After(time.Second * 10): a.Warning("not all discoverers exited") } a.trySend(in) @@ -83,7 +83,7 @@ func (a *accumulator) runDiscoverer(ctx context.Context, d model.Discoverer, upd case <-ctx.Done(): select { case <-done: - case <-time.After(time.Second * 2): + case <-time.After(time.Second * 10): a.Warningf("discoverer '%v' didn't exit on ctx done", d) } return diff --git a/src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go b/src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go index 9df7ec59d4..9265a4d793 100644 --- a/src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go +++ b/src/go/plugin/go.d/agent/discovery/sd/pipeline/config.go @@ -10,6 +10,7 @@ import ( "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockerd" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/kubernetes" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlisteners" + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd" ) type Config struct { @@ -28,6 +29,7 @@ type DiscoveryConfig struct { NetListeners netlisteners.Config `yaml:"net_listeners"` Docker dockerd.Config `yaml:"docker"` K8s []kubernetes.Config `yaml:"k8s"` + SNMP snmpsd.Config `yaml:"snmp"` } type ClassifyRuleConfig struct { @@ -71,7 +73,7 @@ func validateDiscoveryConfig(config []DiscoveryConfig) error { } for _, cfg := range config { switch cfg.Discoverer { - case "net_listeners", "docker", "k8s": + case "net_listeners", "docker", "k8s", "snmp": default: return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer) } diff --git a/src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go b/src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go index 4d391d41ef..e7cde64028 100644 --- a/src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go +++ b/src/go/plugin/go.d/agent/discovery/sd/pipeline/pipeline.go @@ -14,6 +14,7 @@ import ( "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/dockerd" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/kubernetes" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/netlisteners" + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/model" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/hostinfo" ) @@ -102,6 +103,12 @@ func (p *Pipeline) registerDiscoverers(conf Config) error { } p.discoverers = append(p.discoverers, td) } + case "snmp": + td, err := snmpsd.NewDiscoverer(cfg.SNMP) + if err != nil { + return fmt.Errorf("failed to create '%s' discoverer: %v", cfg.Discoverer, err) + } + p.discoverers = append(p.discoverers, td) default: return fmt.Errorf("unknown discoverer: '%s'", cfg.Discoverer) } @@ -130,7 +137,7 @@ func (p *Pipeline) Run(ctx context.Context, in chan<- []*confgroup.Group) { case <-ctx.Done(): select { case <-done: - case <-time.After(time.Second * 4): + case <-time.After(time.Second * 10): } return case <-done: @@ -149,7 +156,7 @@ func (p *Pipeline) Run(ctx context.Context, in chan<- []*confgroup.Group) { func (p *Pipeline) processGroups(tggs []model.TargetGroup) []*confgroup.Group { var groups []*confgroup.Group - // updates come from the accumulator, this ensures that all groups have different sources + // updates come from the accumulator; this ensures that all groups have different sources for _, tgg := range tggs { p.Debugf("processing group '%s' with %d target(s)", tgg.Source(), len(tgg.Targets())) if v := p.processGroup(tgg); v != nil { diff --git a/src/go/plugin/go.d/agent/filepersister/persister.go b/src/go/plugin/go.d/agent/filepersister/persister.go index 2f49cdd1cd..251a3b3a1e 100644 --- a/src/go/plugin/go.d/agent/filepersister/persister.go +++ b/src/go/plugin/go.d/agent/filepersister/persister.go @@ -16,14 +16,21 @@ type Data interface { Updated() <-chan struct{} } +func Save(path string, data interface{ Bytes() ([]byte, error) }) { + if path == "" { + return + } + New(path).flush(data) +} + func New(path string) *Persister { return &Persister{ Logger: logger.New().With( slog.String("component", "file persister"), slog.String("file", path), ), + FlushEvery: time.Minute * 1, filepath: path, - flushEvery: time.Second * 5, flushCh: make(chan struct{}, 1), } } @@ -31,10 +38,11 @@ func New(path string) *Persister { type Persister struct { *logger.Logger - data Data - filepath string - flushEvery time.Duration - flushCh chan struct{} + FlushEvery time.Duration + + data Data + filepath string + flushCh chan struct{} } func (p *Persister) Run(ctx context.Context, data Data) { @@ -43,9 +51,9 @@ func (p *Persister) Run(ctx context.Context, data Data) { p.data = data - tk := time.NewTicker(p.flushEvery) + tk := time.NewTicker(p.FlushEvery) defer tk.Stop() - defer p.flush() + defer p.flush(p.data) for { select { @@ -70,14 +78,14 @@ func (p *Persister) triggerFlush() { func (p *Persister) tryFlush() { select { case <-p.flushCh: - p.flush() + p.flush(p.data) default: // no pending flush } } -func (p *Persister) flush() { - bs, err := p.data.Bytes() +func (p *Persister) flush(data interface{ Bytes() ([]byte, error) }) { + bs, err := data.Bytes() if err != nil { p.Debugf("failed to marshal data: %v", err) return diff --git a/src/go/plugin/go.d/agent/jobmgr/filestatus.go b/src/go/plugin/go.d/agent/jobmgr/filestatus.go index d560852cd0..4b79f1d4c8 100644 --- a/src/go/plugin/go.d/agent/jobmgr/filestatus.go +++ b/src/go/plugin/go.d/agent/jobmgr/filestatus.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "os" + "path/filepath" "slices" "sync" @@ -13,14 +14,18 @@ import ( "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/filepersister" ) +func statusFileName(dir string) string { + return filepath.Join(dir, "god-jobs-statuses.json") +} + func (m *Manager) loadFileStatus() { m.fileStatus = newFileStatus() - if isTerminal || m.StateFile == "" { + if isTerminal || m.VarLibDir == "" { return } - s, err := loadFileStatus(m.StateFile) + s, err := loadFileStatus(statusFileName(m.VarLibDir)) if err != nil { m.Warningf("failed to load state file: %v", err) return @@ -29,10 +34,12 @@ func (m *Manager) loadFileStatus() { } func (m *Manager) runFileStatusPersistence() { - if m.StateFile == "" { + if m.VarLibDir == "" { return } - p := filepersister.New(m.StateFile) + + p := filepersister.New(statusFileName(m.VarLibDir)) + p.Run(m.ctx, m.fileStatus) } diff --git a/src/go/plugin/go.d/agent/jobmgr/manager.go b/src/go/plugin/go.d/agent/jobmgr/manager.go index b36cd9f5de..beb8e6b63f 100644 --- a/src/go/plugin/go.d/agent/jobmgr/manager.go +++ b/src/go/plugin/go.d/agent/jobmgr/manager.go @@ -60,7 +60,7 @@ type Manager struct { Out io.Writer Modules module.Registry ConfigDefaults confgroup.Registry - StateFile string + VarLibDir string FnReg FunctionRegistry Vnodes map[string]*vnodes.VirtualNode diff --git a/src/go/plugin/go.d/collector/snmp/charts.go b/src/go/plugin/go.d/collector/snmp/charts.go index 159372fbb6..624489e1c8 100644 --- a/src/go/plugin/go.d/collector/snmp/charts.go +++ b/src/go/plugin/go.d/collector/snmp/charts.go @@ -162,8 +162,8 @@ func (c *Collector) addNetIfaceCharts(iface *netInterface) { for _, chart := range *charts { chart.ID = fmt.Sprintf(chart.ID, cleanIfaceName(iface.ifName)) chart.Labels = []module.Label{ - {Key: "vendor", Value: c.sysInfo.organization}, - {Key: "sysName", Value: c.sysInfo.name}, + {Key: "vendor", Value: c.sysInfo.Organization}, + {Key: "sysName", Value: c.sysInfo.Name}, {Key: "ifDescr", Value: iface.ifDescr}, {Key: "ifName", Value: iface.ifName}, {Key: "ifType", Value: ifTypeMapping[iface.ifType]}, @@ -191,8 +191,8 @@ func (c *Collector) removeNetIfaceCharts(iface *netInterface) { func (c *Collector) addSysUptimeChart() { chart := uptimeChart.Copy() chart.Labels = []module.Label{ - {Key: "vendor", Value: c.sysInfo.organization}, - {Key: "sysName", Value: c.sysInfo.name}, + {Key: "vendor", Value: c.sysInfo.Organization}, + {Key: "sysName", Value: c.sysInfo.Name}, } if err := c.Charts().Add(chart); err != nil { c.Warning(err) diff --git a/src/go/plugin/go.d/collector/snmp/collect.go b/src/go/plugin/go.d/collector/snmp/collect.go index 0d2e75419e..944061817c 100644 --- a/src/go/plugin/go.d/collector/snmp/collect.go +++ b/src/go/plugin/go.d/collector/snmp/collect.go @@ -3,10 +3,12 @@ package snmp import ( + "errors" "fmt" "slices" "strings" + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes" "github.com/google/uuid" @@ -15,7 +17,7 @@ import ( func (c *Collector) collect() (map[string]int64, error) { if c.sysInfo == nil { - si, err := c.getSysInfo() + si, err := snmpsd.GetSysInfo(c.snmpClient) if err != nil { return nil, err } @@ -49,6 +51,24 @@ func (c *Collector) collect() (map[string]int64, error) { return mx, nil } +func (c *Collector) collectSysUptime(mx map[string]int64) error { + resp, err := c.snmpClient.Get([]string{snmpsd.OidSysUptime}) + if err != nil { + return err + } + if len(resp.Variables) == 0 { + return errors.New("no system uptime") + } + v, err := pduToInt(resp.Variables[0]) + if err != nil { + return err + } + + mx["uptime"] = v / 100 // the time is in hundredths of a second + + return nil +} + func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) { if c.snmpClient.Version() == gosnmp.Version1 { return c.snmpClient.WalkAll(rootOid) @@ -56,12 +76,12 @@ func (c *Collector) walkAll(rootOid string) ([]gosnmp.SnmpPDU, error) { return c.snmpClient.BulkWalkAll(rootOid) } -func (c *Collector) setupVnode(si *sysInfo) *vnodes.VirtualNode { +func (c *Collector) setupVnode(si *snmpsd.SysInfo) *vnodes.VirtualNode { if c.Vnode.GUID == "" { c.Vnode.GUID = uuid.NewSHA1(uuid.NameSpaceDNS, []byte(c.Hostname)).String() } - hostnames := []string{c.Vnode.Hostname, si.name, "snmp-device"} + hostnames := []string{c.Vnode.Hostname, si.Name, "snmp-device"} i := slices.IndexFunc(hostnames, func(s string) bool { return s != "" }) c.Vnode.Hostname = fmt.Sprintf("%s(%s)", hostnames[i], c.Hostname) @@ -71,17 +91,17 @@ func (c *Collector) setupVnode(si *sysInfo) *vnodes.VirtualNode { for k, v := range c.Vnode.Labels { labels[k] = v } - if si.descr != "" { - labels["sysDescr"] = si.descr + if si.Descr != "" { + labels["sysDescr"] = si.Descr } - if si.contact != "" { - labels["sysContact"] = si.contact + if si.Contact != "" { + labels["sysContact"] = si.Contact } - if si.location != "" { - labels["sysLocation"] = si.location + if si.Location != "" { + labels["sysLocation"] = si.Location } // FIXME: vendor should be obtained from sysDescr, org should be used as a fallback - labels["vendor"] = si.organization + labels["vendor"] = si.Organization return &vnodes.VirtualNode{ GUID: c.Vnode.GUID, diff --git a/src/go/plugin/go.d/collector/snmp/collect_sys_info.go b/src/go/plugin/go.d/collector/snmp/collect_sys_info.go index ff0e899cec..36b66f2787 100644 --- a/src/go/plugin/go.d/collector/snmp/collect_sys_info.go +++ b/src/go/plugin/go.d/collector/snmp/collect_sys_info.go @@ -1,90 +1,3 @@ // SPDX-License-Identifier: GPL-3.0-or-later package snmp - -import ( - "errors" - "fmt" - "strings" - - "github.com/netdata/netdata/go/plugins/plugin/go.d/collector/snmp/entnum" -) - -const ( - rootOidMibSystem = "1.3.6.1.2.1.1" - oidSysDescr = "1.3.6.1.2.1.1.1.0" - oidSysObject = "1.3.6.1.2.1.1.2.0" - oidSysUptime = "1.3.6.1.2.1.1.3.0" - oidSysContact = "1.3.6.1.2.1.1.4.0" - oidSysName = "1.3.6.1.2.1.1.5.0" - oidSysLocation = "1.3.6.1.2.1.1.6.0" -) - -type sysInfo struct { - descr string - contact string - name string - location string - - organization string -} - -func (c *Collector) getSysInfo() (*sysInfo, error) { - pdus, err := c.snmpClient.WalkAll(rootOidMibSystem) - if err != nil { - return nil, err - } - - si := &sysInfo{ - name: "unknown", - organization: "Unknown", - } - - r := strings.NewReplacer("\n", "\\n", "\r", "\\r") - - for _, pdu := range pdus { - oid := strings.TrimPrefix(pdu.Name, ".") - - switch oid { - case oidSysDescr: - if si.descr, err = pduToString(pdu); err == nil { - si.descr = r.Replace(si.descr) - } - case oidSysObject: - var sysObj string - if sysObj, err = pduToString(pdu); err == nil { - si.organization = entnum.LookupBySysObject(sysObj) - c.Debugf("device sysObject '%s', organization '%s'", sysObj, si.organization) - } - case oidSysContact: - si.contact, err = pduToString(pdu) - case oidSysName: - si.name, err = pduToString(pdu) - case oidSysLocation: - si.location, err = pduToString(pdu) - } - if err != nil { - return nil, fmt.Errorf("OID '%s': %v", pdu.Name, err) - } - } - - return si, nil -} - -func (c *Collector) collectSysUptime(mx map[string]int64) error { - resp, err := c.snmpClient.Get([]string{oidSysUptime}) - if err != nil { - return err - } - if len(resp.Variables) == 0 { - return errors.New("no system uptime") - } - v, err := pduToInt(resp.Variables[0]) - if err != nil { - return err - } - - mx["uptime"] = v / 100 // the time is in hundredths of a second - - return nil -} diff --git a/src/go/plugin/go.d/collector/snmp/collector.go b/src/go/plugin/go.d/collector/snmp/collector.go index 7c32438215..75c778e54a 100644 --- a/src/go/plugin/go.d/collector/snmp/collector.go +++ b/src/go/plugin/go.d/collector/snmp/collector.go @@ -9,6 +9,7 @@ import ( "fmt" "github.com/netdata/netdata/go/plugins/pkg/matcher" + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/vnodes" @@ -76,7 +77,7 @@ type Collector struct { netInterfaces map[string]*netInterface - sysInfo *sysInfo + sysInfo *snmpsd.SysInfo customOids []string } diff --git a/src/go/plugin/go.d/collector/snmp/collector_test.go b/src/go/plugin/go.d/collector/snmp/collector_test.go index 418f50dce1..81c8f231a5 100644 --- a/src/go/plugin/go.d/collector/snmp/collector_test.go +++ b/src/go/plugin/go.d/collector/snmp/collector_test.go @@ -11,6 +11,7 @@ import ( "strings" "testing" + "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/discovery/sd/discoverer/snmpsd" "github.com/netdata/netdata/go/plugins/plugin/go.d/agent/module" "github.com/golang/mock/gomock" @@ -581,15 +582,15 @@ func setMockClientInitExpect(m *snmpmock.MockHandler) { } func setMockClientSysExpect(m *snmpmock.MockHandler) { - m.EXPECT().WalkAll(rootOidMibSystem).Return([]gosnmp.SnmpPDU{ - {Name: oidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString}, - {Name: oidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier}, - {Name: oidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString}, - {Name: oidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString}, - {Name: oidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString}, + m.EXPECT().WalkAll(snmpsd.RootOidMibSystem).Return([]gosnmp.SnmpPDU{ + {Name: snmpsd.OidSysDescr, Value: []uint8("mock sysDescr"), Type: gosnmp.OctetString}, + {Name: snmpsd.OidSysObject, Value: ".1.3.6.1.4.1.14988.1", Type: gosnmp.ObjectIdentifier}, + {Name: snmpsd.OidSysContact, Value: []uint8("mock sysContact"), Type: gosnmp.OctetString}, + {Name: snmpsd.OidSysName, Value: []uint8("mock sysName"), Type: gosnmp.OctetString}, + {Name: snmpsd.OidSysLocation, Value: []uint8("mock sysLocation"), Type: gosnmp.OctetString}, }, nil).MinTimes(1) - m.EXPECT().Get([]string{oidSysUptime}).Return(&gosnmp.SnmpPacket{ + m.EXPECT().Get([]string{snmpsd.OidSysUptime}).Return(&gosnmp.SnmpPacket{ Variables: []gosnmp.SnmpPDU{ {Value: uint32(6048), Type: gosnmp.TimeTicks}, }, diff --git a/src/go/plugin/go.d/config/go.d/sd/snmp.conf b/src/go/plugin/go.d/config/go.d/sd/snmp.conf new file mode 100644 index 0000000000..493be558e5 --- /dev/null +++ b/src/go/plugin/go.d/config/go.d/sd/snmp.conf @@ -0,0 +1,98 @@ +# =================================================================== +# WARNING: SNMP DISCOVERY IS DISABLED BY DEFAULT +# To enable, change "disabled: yes" to "disabled: no" below +# AND configure proper credentials and networks +# =================================================================== + +disabled: yes + +name: 'snmp' + +discover: + - discoverer: snmp + snmp: + # how often to scan the networks for devices (default: 30m) + rescan_interval: "30m" + + # the maximum time to wait for SNMP device responses (default: 1s) + timeout: "1s" + + # How long to trust cached discovery results before requiring a new probe (default: 12h) + device_cache_ttl: "6h" + + # how many IPs to scan concurrently within each subnet (default: 32) + parallel_scans_per_network: 32 + + # ========================================================== + # IMPORTANT: YOU MUST CONFIGURE YOUR OWN CREDENTIALS BELOW + # The example credentials will not work in most environments + # ========================================================== + credentials: + - name: "public-v2c" + version: "2" + community: "public" + + - name: "secure-v3" + version: "3" + # one of: "noAuthNoPriv", "authNoPriv", or "authPriv" + security_level: "authPriv" + username: "admin" + # one of: "md5", "sha", "sha224", "sha256", "sha384", "sha512" + auth_protocol: "sha" + auth_password: "secret123" + # one of: "des", "aes", "aes192", "aes256", "aes192C", "aes256C" + priv_protocol: "aes" + priv_password: "encrypt123" + + # ======================================================== + # IMPORTANT: YOU MUST CONFIGURE YOUR OWN NETWORKS BELOW + # By default, no networks will be scanned until configured + # Maximum size is limited to 512 IPs per subnet (/23 CIDR) + # ======================================================== + networks: + # Subnet is the IP range to scan, supporting various formats: + # https://github.com/netdata/netdata/tree/master/src/go/plugin/go.d/pkg/iprange#supported-formats + - subnet: "192.168.1.0/24" + # Credential is the name of a credential from the Credentials list + credential: "public-v2c" + +classify: + - name: "Servers" + selector: "*" + tags: "skip" + match: + - tags: "skip" + expr: '{{ match "sp" .SysInfo.Descr "Linux* FreeBSD* OpenBSD* NetBSD*" }}' + - name: "SNMP Devices" + selector: "!skip *" + tags: "snmp" + match: + - tags: "snmp" + expr: '{{ true }}' +compose: + - name: "SNMP Devices" + selector: "snmp" + config: + - selector: "snmp" + template: | + module: snmp + update_every: 5 + {{- if .SysInfo.Name }} + name: {{ .SysInfo.Name }}-ip-{{ .IPAddress }} + {{- else }} + name: ip-{{ .IPAddress }} + {{- end }} + hostname: {{ .IPAddress }} + options: + version: {{ .Credential.Version }} + {{- if eq .Credential.Version "1" "2" }} + community: {{ .Credential.Community }} + {{- else }} + user: + name: {{ .Credential.UserName }} + level: {{ .Credential.SecurityLevel }} + auth_proto: {{ .Credential.AuthProtocol }} + auth_key: {{ .Credential.AuthPassphrase }} + priv_proto: {{ .Credential.PrivacyProtocol }} + priv_key: {{ .Credential.PrivacyPassphrase }} + {{- end }} diff --git a/src/go/plugin/go.d/pkg/confopt/duration.go b/src/go/plugin/go.d/pkg/confopt/duration.go index 7aebe062a2..dd2afa0584 100644 --- a/src/go/plugin/go.d/pkg/confopt/duration.go +++ b/src/go/plugin/go.d/pkg/confopt/duration.go @@ -5,10 +5,70 @@ package confopt import ( "encoding/json" "fmt" + "regexp" "strconv" + "strings" "time" ) +var reDuration = regexp.MustCompile(`(\d+(?:\.\d+)?)\s*(ns|us|µs|μs|ms|s|mo|m|h|d|wk|w|M|y)`) + +// ParseDuration parses a duration string with units. +func ParseDuration(s string) (time.Duration, error) { + orig := s + + if s = strings.ReplaceAll(s, " ", ""); s == "" { + return 0, fmt.Errorf("empty duration string") + } + + neg := s[0] == '-' + if neg { + s = s[1:] + } + + unitMap := map[string]time.Duration{ + "d": 24 * time.Hour, + "w": 7 * 24 * time.Hour, + "wk": 7 * 24 * time.Hour, + "mo": 30 * 24 * time.Hour, + "M": 30 * 24 * time.Hour, + "y": 365 * 24 * time.Hour, + } + + matches := reDuration.FindAllStringSubmatch(s, -1) + + if len(matches) == 0 { + return 0, fmt.Errorf("invalid duration format: '%s'", orig) + } + + var total time.Duration + + for _, m := range matches { + value, unit := m[1], m[2] + + val, err := strconv.ParseFloat(value, 64) + if err != nil { + return 0, fmt.Errorf("invalid number: %s", value) + } + + if multiplier, ok := unitMap[unit]; ok { + total += time.Duration(val * float64(multiplier)) + } else { + dur, err := time.ParseDuration(value + unit) + if err != nil { + return 0, fmt.Errorf("invalid duration unit: %s", value+unit) + } + total += dur + } + } + + if neg { + total = -total + } + + return total, nil +} + type Duration time.Duration func (d Duration) Duration() time.Duration { @@ -26,7 +86,7 @@ func (d *Duration) UnmarshalYAML(unmarshal func(any) error) error { return err } - if v, err := time.ParseDuration(s); err == nil { + if v, err := ParseDuration(s); err == nil { *d = Duration(v) return nil } diff --git a/src/go/plugin/go.d/pkg/confopt/duration_test.go b/src/go/plugin/go.d/pkg/confopt/duration_test.go index fe907bf530..eb9cea82d0 100644 --- a/src/go/plugin/go.d/pkg/confopt/duration_test.go +++ b/src/go/plugin/go.d/pkg/confopt/duration_test.go @@ -5,6 +5,7 @@ package confopt import ( "encoding/json" "fmt" + "math" "strings" "testing" "time" @@ -15,6 +16,80 @@ import ( "gopkg.in/yaml.v2" ) +func TestParseDuration(t *testing.T) { + tests := map[string]struct { + input string + wantDuration time.Duration + wantErr bool + }{ + "nanoseconds": {input: "10ns", wantDuration: 10 * time.Nanosecond}, + "microseconds": {input: "10us", wantDuration: 10 * time.Microsecond}, + "milliseconds": {input: "10ms", wantDuration: 10 * time.Millisecond}, + "seconds": {input: "10s", wantDuration: 10 * time.Second}, + "minutes": {input: "10m", wantDuration: 10 * time.Minute}, + "hours": {input: "10h", wantDuration: 10 * time.Hour}, + "days": {input: "10d", wantDuration: 10 * 24 * time.Hour}, + "weeks (w)": {input: "10w", wantDuration: 10 * 7 * 24 * time.Hour}, + "weeks (wk)": {input: "10wk", wantDuration: 10 * 7 * 24 * time.Hour}, + "months (mo)": {input: "10mo", wantDuration: 10 * 30 * 24 * time.Hour}, + "months (M)": {input: "10M", wantDuration: 10 * 30 * 24 * time.Hour}, + "years": {input: "10y", wantDuration: 10 * 365 * 24 * time.Hour}, + "negative units": {input: "-10d", wantDuration: -10 * 24 * time.Hour}, + "mixed units": { + input: "1y2M3w4d5h6m7s8ms9us10ns", + wantDuration: (1 * 365 * 24 * time.Hour) + + (2 * 30 * 24 * time.Hour) + + (3 * 7 * 24 * time.Hour) + + (4 * 24 * time.Hour) + + (5 * time.Hour) + + (6 * time.Minute) + + (7 * time.Second) + + (8 * time.Millisecond) + + (9 * time.Microsecond) + + (10 * time.Nanosecond), + }, + "mixed units with spaces": { + input: "1y 2M 3w 4d 5h 6m 7s 8ms 9us 10ns", + wantDuration: (1 * 365 * 24 * time.Hour) + + (2 * 30 * 24 * time.Hour) + + (3 * 7 * 24 * time.Hour) + + (4 * 24 * time.Hour) + + (5 * time.Hour) + + (6 * time.Minute) + + (7 * time.Second) + + (8 * time.Millisecond) + + (9 * time.Microsecond) + + (10 * time.Nanosecond), + }, + "mixed units with decimals": { + input: "1.5y2.25M3.75w4.5d5.5h6.5m7.5s8.5ms9.5us10.5ns", + wantDuration: time.Duration(math.Floor(1.5*365*24*float64(time.Hour))) + + time.Duration(math.Floor(2.25*30*24*float64(time.Hour))) + + time.Duration(math.Floor(3.75*7*24*float64(time.Hour))) + + time.Duration(math.Floor(4.5*24*float64(time.Hour))) + + time.Duration(math.Floor(5.5*float64(time.Hour))) + + time.Duration(math.Floor(6.5*float64(time.Minute))) + + time.Duration(math.Floor(7.5*float64(time.Second))) + + time.Duration(math.Floor(8.5*float64(time.Millisecond))) + + time.Duration(math.Floor(9.5*float64(time.Microsecond))) + + time.Duration(math.Floor(10.5*float64(time.Nanosecond))), + }, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + dur, err := ParseDuration(test.input) + + if test.wantErr { + assert.Error(t, err) + } else { + require.NoError(t, err) + assert.Equal(t, test.wantDuration, dur) + } + }) + } +} + func TestDuration_MarshalYAML(t *testing.T) { tests := map[string]struct { d Duration