diff --git a/README.md b/README.md index 6b65778ec..24d8a1ac2 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,40 @@ export MONGODB_PASSWORD=YYY mongodb_exporter_linux_amd64/mongodb_exporter --mongodb.uri=mongodb://127.0.0.1:17001 --mongodb.collstats-colls=db1.c1,db2.c2 ``` +#### Dynamic target support + +Dynamic target mode accepts the MongoDB address at scrape time, so the exporter does not need a predefined `MONGODB_URI`. Credentials and connection options stay in a local YAML file: + +```yaml +auth_modules: + cloud_mongo: + type: userpass + userpass: + username: metrics_exporter + password: CHANGE_ME + options: + auth_source: admin + tls: false + tls_insecure_skip_verify: false +``` + +Start the exporter without `--mongodb.uri`: + +```sh +mongodb_exporter --config.file=/etc/mongodb-exporter/mongodb_exporter.yml \ + --collector.diagnosticdata \ + --collector.dbstats \ + --collector.shards +``` + +Pass a discovered target and authentication module to `/probe`: + +```sh +curl 'http://127.0.0.1:9216/probe?target=mongo.example.com:3717&auth_module=cloud_mongo' +``` + +The target may contain only one hostname and an optional port. Credentials, paths, and query parameters are rejected; these values must come from the selected authentication module. If the config contains exactly one authentication module, `auth_module` may be omitted. + #### Multi-target support You can run the exporter specifying multiple URIs, devided by a comma in --mongodb.uri option or MONGODB_URI environment variable in order to monitor multiple mongodb instances with the a single mongodb_exporter instance. ```sh diff --git a/REFERENCE.md b/REFERENCE.md index 38c2eea61..fb5c69615 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -4,6 +4,7 @@ | Flag | Description | Example | |-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|------------------------------------------------------------------| | -h, \-\-help | Show context-sensitive help | | +| --config.file | Path to the dynamic target authentication config file | --config.file=/etc/mongodb-exporter/mongodb_exporter.yml | | --[no-]compatible-mode | Enable old mongodb-exporter compatible metrics | | | --[no-]discovering-mode | Enable autodiscover collections | | | --mongodb.collstats-colls | List of comma separared databases.collections to get $collStats | --mongodb.collstats-colls=db1,db2.col2 | diff --git a/auth_config.go b/auth_config.go new file mode 100644 index 000000000..51e159f67 --- /dev/null +++ b/auth_config.go @@ -0,0 +1,142 @@ +// mongodb_exporter +// Copyright (C) 2026 Percona LLC +// +// 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. + +// Package main runs the MongoDB exporter. +package main + +import ( + "bytes" + "errors" + "fmt" + "net/url" + "os" + + "gopkg.in/yaml.v3" +) + +const ( + userPassAuthType = "userpass" + defaultAuthSource = "admin" +) + +var ( + errUnsupportedAuthModuleType = errors.New("unsupported auth module type") + errAuthModuleCredentials = errors.New("auth module username and password are required") + errAuthModuleTLS = errors.New("auth module tls must be enabled when tls_insecure_skip_verify is true") + errAuthModuleNotFound = errors.New("auth module not found") + errAuthModuleRequired = errors.New("auth_module is required") + errNoAuthModules = errors.New("no auth modules configured") +) + +type authConfig struct { + AuthModules map[string]authModule `yaml:"auth_modules"` //nolint:tagliatelle // Match Prometheus auth module config conventions. +} + +type authModule struct { + Type string `yaml:"type"` + UserPass userPass `yaml:"userpass"` + Options authModuleOptions `yaml:"options"` +} + +type userPass struct { + Username string `yaml:"username"` + Password string `yaml:"password"` +} + +type authModuleOptions struct { + AuthSource string `yaml:"auth_source"` //nolint:tagliatelle // Match Prometheus auth module config conventions. + TLS bool `yaml:"tls"` + TLSInsecureSkipVerify bool `yaml:"tls_insecure_skip_verify"` //nolint:tagliatelle // Match Prometheus auth module config conventions. +} + +func loadAuthConfig(path string) (authConfig, error) { + if path == "" { + return authConfig{}, nil + } + + data, err := os.ReadFile(path) //nolint:gosec // The operator explicitly selects this config file. + if err != nil { + return authConfig{}, fmt.Errorf("read config file: %w", err) + } + + var config authConfig + decoder := yaml.NewDecoder(bytes.NewReader(data)) + decoder.KnownFields(true) + err = decoder.Decode(&config) + if err != nil { + return authConfig{}, fmt.Errorf("decode config file: %w", err) + } + + for name, module := range config.AuthModules { + if module.Type != userPassAuthType { + return authConfig{}, fmt.Errorf("%w: module %q has type %q", errUnsupportedAuthModuleType, name, module.Type) + } + if module.UserPass.Username == "" || module.UserPass.Password == "" { + return authConfig{}, fmt.Errorf("%w: %q", errAuthModuleCredentials, name) + } + if module.Options.AuthSource == "" { + module.Options.AuthSource = defaultAuthSource + config.AuthModules[name] = module + } + if module.Options.TLSInsecureSkipVerify && !module.Options.TLS { + return authConfig{}, fmt.Errorf("%w: %q", errAuthModuleTLS, name) + } + } + + return config, nil +} + +func resolveAuthModule(modules map[string]authModule, name string) (authModule, error) { + if name != "" { + module, ok := modules[name] + if !ok { + return authModule{}, fmt.Errorf("%w: %q", errAuthModuleNotFound, name) + } + + return module, nil + } + + if len(modules) != 1 { + return authModule{}, errAuthModuleRequired + } + for _, module := range modules { + return module, nil + } + + return authModule{}, errNoAuthModules +} + +func buildDynamicURI(target string, module authModule) string { + authSource := module.Options.AuthSource + if authSource == "" { + authSource = defaultAuthSource + } + uri := &url.URL{ + Scheme: "mongodb", + User: url.UserPassword(module.UserPass.Username, module.UserPass.Password), + Host: target, + Path: "/" + authSource, + } + query := url.Values{"authSource": []string{authSource}} + if module.Options.TLS { + query.Set("tls", "true") + } + if module.Options.TLSInsecureSkipVerify { + query.Set("tlsInsecure", "true") + } + uri.RawQuery = query.Encode() + + return uri.String() +} diff --git a/exporter/multi_target_test.go b/exporter/multi_target_test.go index 8278591d9..85c064492 100644 --- a/exporter/multi_target_test.go +++ b/exporter/multi_target_test.go @@ -16,20 +16,105 @@ package exporter import ( + "context" "fmt" "io" "net" "net/http" "net/http/httptest" + "net/url" "regexp" + "strings" "testing" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/percona/mongodb_exporter/internal/tu" ) +func TestDynamicTarget(t *testing.T) { + t.Parallel() + + var gotTarget, gotAuthModule string + factory := func(target, authModule string) (http.Handler, error) { + gotTarget = target + gotAuthModule = authModule + + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), nil + } + + rr := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/probe?target=mongo.example.com:3717&auth_module=cloud_mongo", nil) + multiTargetHandler(nil, factory)(rr, req) + + assert.Equal(t, http.StatusNoContent, rr.Code) + assert.Equal(t, "mongo.example.com:3717", gotTarget) + assert.Equal(t, "cloud_mongo", gotAuthModule) +} + +func TestDynamicTargetValidation(t *testing.T) { + t.Parallel() + + factory := func(_, _ string) (http.Handler, error) { + return nil, assert.AnError + } + tests := []string{ + "mongodb://user:password@mongo.example.com:3717", + "mongodb://mongo.example.com:3717/admin", + "mongodb://mongo.example.com:3717?tls=true", + } + + for _, target := range tests { + rr := httptest.NewRecorder() + req := httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/probe?target="+url.QueryEscape(target), nil) + multiTargetHandler(nil, factory)(rr, req) + assert.Equal(t, http.StatusBadRequest, rr.Code, target) + } +} + +func TestDynamicOnlyServer(t *testing.T) { + t.Parallel() + + factory := func(target, authModule string) (http.Handler, error) { + assert.Equal(t, "mongo.example.com:3717", target) + assert.Equal(t, "cloud_mongo", authModule) + + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNoContent) + }), nil + } + opts := &ServerOpts{ + Path: "/metrics", + MultiTargetPath: "/scrape", + DynamicTargetPath: "/probe", + DisableDefaultRegistry: true, + DynamicTargetFactory: factory, + } + + handler, err := newWebHandler(opts, nil, promslog.New(&promslog.Config{})) + require.NoError(t, err) + + metricsRecorder := httptest.NewRecorder() + handler.ServeHTTP(metricsRecorder, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/metrics", nil)) + assert.Equal(t, http.StatusOK, metricsRecorder.Code) + assert.Empty(t, strings.TrimSpace(metricsRecorder.Body.String())) + + probeRecorder := httptest.NewRecorder() + handler.ServeHTTP(probeRecorder, httptest.NewRequestWithContext(context.Background(), http.MethodGet, "/probe?target=mongo.example.com:3717&auth_module=cloud_mongo", nil)) + assert.Equal(t, http.StatusNoContent, probeRecorder.Code) +} + +func TestServerRequiresStaticOrDynamicTarget(t *testing.T) { + t.Parallel() + + _, err := newWebHandler(&ServerOpts{Path: "/metrics"}, nil, promslog.New(&promslog.Config{})) + assert.ErrorContains(t, err, "no exporters were built") +} + func TestMultiTarget(t *testing.T) { hostname := "127.0.0.1" opts := []*Opts{ @@ -71,7 +156,7 @@ func TestMultiTarget(t *testing.T) { // Test all targets for sn, opt := range opts { - assert.HTTPBodyContains(t, multiTargetHandler(serverMap), "GET", fmt.Sprintf("?target=%s", opt.URI), nil, expected[sn]) + assert.HTTPBodyContains(t, multiTargetHandler(serverMap, nil), "GET", "?target="+opt.URI, nil, expected[sn]) } } diff --git a/exporter/server.go b/exporter/server.go index e7c4f5cd4..87e6614f2 100644 --- a/exporter/server.go +++ b/exporter/server.go @@ -17,6 +17,7 @@ package exporter import ( "context" + "errors" "log/slog" "net/http" "net/url" @@ -30,50 +31,41 @@ import ( "github.com/prometheus/exporter-toolkit/web" ) -// ServerMap stores http handlers for each host +var ( + errNoExporters = errors.New("no exporters were built; specify --mongodb.uri, MONGODB_URI, or a dynamic target config") + errTargetRequired = errors.New("target is required") + errInvalidTarget = errors.New("invalid MongoDB target") + errUnsupportedTargetURL = errors.New("target must contain only one MongoDB host and optional port") +) + +// ServerMap stores http handlers for each host. type ServerMap map[string]http.Handler +// DynamicTargetFactory builds a handler for a target supplied at scrape time. +type DynamicTargetFactory func(target, authModule string) (http.Handler, error) + // ServerOpts is the options for the main http handler type ServerOpts struct { Path string MultiTargetPath string + DynamicTargetPath string OverallTargetPath string WebListenAddress string TLSConfigPath string DisableDefaultRegistry bool + DynamicTargetFactory DynamicTargetFactory } // RunWebServer runs the main web-server func RunWebServer(opts *ServerOpts, exporters []*Exporter, log *slog.Logger) { - mux := http.NewServeMux() - - if len(exporters) == 0 { - panic("No exporters were built. You must specify --mongodb.uri command argument or MONGODB_URI environment variable") + handler, err := newWebHandler(opts, exporters, log) + if err != nil { + panic(err) } - serverMap := buildServerMap(exporters, log) - - defaultExporter := exporters[0] - mux.Handle(opts.Path, defaultExporter.Handler()) - mux.HandleFunc(opts.MultiTargetPath, multiTargetHandler(serverMap)) - mux.HandleFunc(opts.OverallTargetPath, OverallTargetsHandler(exporters, log)) - - mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { - _, err := w.Write([]byte(` - MongoDB Exporter - -

MongoDB Exporter

-

Metrics

- - `)) - if err != nil { - log.Error("error writing response", "error", err) - } - }) - server := &http.Server{ ReadHeaderTimeout: 2 * time.Second, - Handler: mux, + Handler: handler, } flags := &web.FlagConfig{ WebListenAddresses: &[]string{opts.WebListenAddress}, @@ -85,7 +77,52 @@ func RunWebServer(opts *ServerOpts, exporters []*Exporter, log *slog.Logger) { } } -func multiTargetHandler(serverMap ServerMap) http.HandlerFunc { +func newWebHandler(opts *ServerOpts, exporters []*Exporter, log *slog.Logger) (http.Handler, error) { + if len(exporters) == 0 && opts.DynamicTargetFactory == nil { + return nil, errNoExporters + } + + mux := http.NewServeMux() + serverMap := buildServerMap(exporters, log) + switch { + case len(exporters) > 0: + mux.Handle(opts.Path, exporters[0].Handler()) + case opts.DisableDefaultRegistry: + mux.Handle(opts.Path, promhttp.HandlerFor(prometheus.NewRegistry(), promhttp.HandlerOpts{})) + default: + mux.Handle(opts.Path, promhttp.Handler()) + } + + targetHandler := multiTargetHandler(serverMap, opts.DynamicTargetFactory) + if opts.MultiTargetPath != "" { + mux.HandleFunc(opts.MultiTargetPath, targetHandler) + } + if opts.DynamicTargetPath != "" && opts.DynamicTargetPath != opts.MultiTargetPath { + mux.HandleFunc(opts.DynamicTargetPath, targetHandler) + } + if opts.OverallTargetPath != "" { + mux.HandleFunc(opts.OverallTargetPath, OverallTargetsHandler(exporters, log)) + } + + if opts.Path != "/" { + mux.HandleFunc("/", func(w http.ResponseWriter, _ *http.Request) { + _, err := w.Write([]byte(` + MongoDB Exporter + +

MongoDB Exporter

+

Metrics

+ + `)) + if err != nil { + log.Error("error writing response", "error", err) + } + }) + } + + return mux, nil +} + +func multiTargetHandler(serverMap ServerMap, factory DynamicTargetFactory) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { targetHost := r.URL.Query().Get("target") if targetHost != "" { @@ -95,14 +132,60 @@ func multiTargetHandler(serverMap ServerMap) http.HandlerFunc { if uri, err := url.Parse(targetHost); err == nil { if e, ok := serverMap[uri.Host]; ok { e.ServeHTTP(w, r) + return } } } - http.Error(w, "Unable to find target", http.StatusNotFound) + + if factory != nil { + target, err := parseDynamicTarget(targetHost) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + + handler, err := factory(target, r.URL.Query().Get("auth_module")) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + + return + } + if handler == nil { + http.Error(w, "unable to build target handler", http.StatusInternalServerError) + + return + } + + handler.ServeHTTP(w, r) + + return + } + + http.Error(w, "unable to find target", http.StatusNotFound) } } +func parseDynamicTarget(target string) (string, error) { + if target == "" { + return "", errTargetRequired + } + if !strings.HasPrefix(target, "mongodb://") { + target = "mongodb://" + target + } + + uri, err := url.Parse(target) + if err != nil || uri.Host == "" { + return "", errInvalidTarget + } + if uri.Scheme != "mongodb" || uri.User != nil || uri.Path != "" || uri.RawQuery != "" || uri.Fragment != "" || strings.Contains(uri.Host, ",") { + return "", errUnsupportedTargetURL + } + + return uri.Host, nil +} + // OverallTargetsHandler is a handler to scrape all the targets in one request. // Adds instance label to each metric. func OverallTargetsHandler(exporters []*Exporter, logger *slog.Logger) http.HandlerFunc { diff --git a/go.mod b/go.mod index ab0b3bded..2120abdce 100644 --- a/go.mod +++ b/go.mod @@ -20,6 +20,7 @@ require github.com/foxcpp/go-mockdns v1.2.0 require ( github.com/hashicorp/go-version v1.9.0 github.com/percona/percona-backup-mongodb v1.8.1-0.20251124214042-d06cab743541 + gopkg.in/yaml.v3 v3.0.1 ) require ( @@ -149,7 +150,6 @@ require ( google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.1 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect mvdan.cc/gofumpt v0.9.2 // indirect ) diff --git a/main.go b/main.go index 69c893918..482c8d788 100644 --- a/main.go +++ b/main.go @@ -16,13 +16,16 @@ package main import ( + "errors" "fmt" "log" "log/slog" "net" + "net/http" "net/url" "regexp" "strings" + "sync" "github.com/alecthomas/kong" "github.com/prometheus/common/promslog" @@ -37,8 +40,12 @@ var ( buildDate string ) +var errNoMongoTargets = errors.New("no MongoDB targets configured: specify --mongodb.uri, MONGODB_URI, or --config.file with auth_modules") + // GlobalFlags has command line flags to configure the exporter. type GlobalFlags struct { + ConfigFile string `help:"Path to the dynamic target authentication config file" name:"config.file"` + User string `env:"MONGODB_USER" help:"monitor user, need clusterMonitor role in admin db and read role in local db" name:"mongodb.user" placeholder:"monitorUser"` Password string `env:"MONGODB_PASSWORD" help:"monitor user password" name:"mongodb.password" placeholder:"monitorPassword"` CollStatsNamespaces string `help:"List of comma separared databases.collections to get $collStats" name:"mongodb.collstats-colls" placeholder:"db1,db2.col2"` @@ -119,8 +126,13 @@ func main() { opts.WebTelemetryPath = "/" } - if len(opts.URI) == 0 { - ctx.Fatalf("No MongoDB hosts were specified. You must specify the host(s) with the --mongodb.uri command argument or the MONGODB_URI environment variable") + authConfig, err := loadAuthConfig(opts.ConfigFile) + if err != nil { + ctx.Fatalf("Cannot load auth config: %v", err) + } + err = validateTargetConfiguration(opts, authConfig) + if err != nil { + ctx.FatalIfErrorf(err) } if opts.TimeoutOffset <= 0 { @@ -129,18 +141,62 @@ func main() { } serverOpts := &exporter.ServerOpts{ - Path: opts.WebTelemetryPath, - MultiTargetPath: "/scrape", - OverallTargetPath: "/scrapeall", - WebListenAddress: opts.WebListenAddress, - TLSConfigPath: opts.TLSConfigPath, + Path: opts.WebTelemetryPath, + MultiTargetPath: "/scrape", + DynamicTargetPath: "/probe", + OverallTargetPath: "/scrapeall", + WebListenAddress: opts.WebListenAddress, + TLSConfigPath: opts.TLSConfigPath, + DynamicTargetFactory: newDynamicTargetFactory(opts, authConfig, logger), + DisableDefaultRegistry: !opts.EnableExporterMetrics, } exporter.RunWebServer(serverOpts, buildServers(opts, logger), logger) } +func validateTargetConfiguration(opts GlobalFlags, config authConfig) error { + if len(opts.URI) == 0 && len(config.AuthModules) == 0 { + return errNoMongoTargets + } + + return nil +} + +func newDynamicTargetFactory(opts GlobalFlags, config authConfig, logger *slog.Logger) exporter.DynamicTargetFactory { + if len(config.AuthModules) == 0 { + return nil + } + + var mu sync.Mutex + // ponytail: service-discovery targets are stable; add bounded eviction only if target churn becomes measurable. + handlers := make(map[string]http.Handler) + + return func(target, authModule string) (http.Handler, error) { + module, err := resolveAuthModule(config.AuthModules, authModule) + if err != nil { + return nil, err + } + + cacheKey := authModule + "\x00" + target + mu.Lock() + defer mu.Unlock() + if handler, ok := handlers[cacheKey]; ok { + return handler, nil + } + + dynamicOpts := opts + dynamicOpts.User = "" + dynamicOpts.Password = "" + dynamicOpts.URI = nil + handler := buildExporter(dynamicOpts, buildDynamicURI(target, module), logger).Handler() + handlers[cacheKey] = handler + + return handler, nil + } +} + func buildExporter(opts GlobalFlags, uri string, log *slog.Logger) *exporter.Exporter { uri = buildURI(uri, opts.User, opts.Password) - log.Debug("Connection URI", "uri", uri) + log.Debug("Connection URI", "uri", redactMongoURI(uri)) uriParsed, _ := url.Parse(uri) var nodeName string @@ -202,6 +258,15 @@ func buildExporter(opts GlobalFlags, uri string, log *slog.Logger) *exporter.Exp return exporter.New(exporterOpts) } +func redactMongoURI(rawURI string) string { + uri, err := url.Parse(rawURI) + if err != nil { + return "" + } + + return uri.Redacted() +} + func buildServers(opts GlobalFlags, logger *slog.Logger) []*exporter.Exporter { URIs := parseURIList(opts.URI, logger, opts.SplitCluster) servers := make([]*exporter.Exporter, len(URIs)) diff --git a/main_test.go b/main_test.go index 3d54f866e..541a3796c 100644 --- a/main_test.go +++ b/main_test.go @@ -17,16 +17,121 @@ package main import ( "net" + "os" + "path/filepath" "strings" "testing" "github.com/foxcpp/go-mockdns" "github.com/prometheus/common/promslog" "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/percona/mongodb_exporter/internal/tu" ) +func TestLoadAuthConfig(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "mongodb_exporter.yml") + err := os.WriteFile(path, []byte(` +auth_modules: + cloud_mongo: + type: userpass + userpass: + username: metrics + password: secret + options: + auth_source: admin + tls: true +`), 0o600) + require.NoError(t, err) + + config, err := loadAuthConfig(path) + require.NoError(t, err) + assert.Equal(t, "metrics", config.AuthModules["cloud_mongo"].UserPass.Username) + assert.Equal(t, "admin", config.AuthModules["cloud_mongo"].Options.AuthSource) + assert.True(t, config.AuthModules["cloud_mongo"].Options.TLS) +} + +func TestLoadAuthConfigRejectsInvalidModule(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "mongodb_exporter.yml") + err := os.WriteFile(path, []byte(` +auth_modules: + cloud_mongo: + type: unsupported +`), 0o600) + require.NoError(t, err) + + _, err = loadAuthConfig(path) + assert.ErrorContains(t, err, "cloud_mongo") +} + +func TestBuildDynamicURI(t *testing.T) { + t.Parallel() + + module := authModule{ + Type: userPassAuthType, + UserPass: userPass{ + Username: "metrics", + Password: "p@ssword", + }, + Options: authModuleOptions{ + AuthSource: "admin", + TLS: true, + TLSInsecureSkipVerify: true, + }, + } + + uri := buildDynamicURI("mongo.example.com:3717", module) + assert.Equal(t, "mongodb://metrics:p%40ssword@mongo.example.com:3717/admin?authSource=admin&tls=true&tlsInsecure=true", uri) +} + +func TestResolveAuthModule(t *testing.T) { + t.Parallel() + + modules := map[string]authModule{"cloud_mongo": {Type: userPassAuthType}} + module, err := resolveAuthModule(modules, "") + require.NoError(t, err) + assert.Equal(t, userPassAuthType, module.Type) + + _, err = resolveAuthModule(modules, "missing") + assert.ErrorContains(t, err, "missing") +} + +func TestValidateTargetConfiguration(t *testing.T) { + t.Parallel() + + require.Error(t, validateTargetConfiguration(GlobalFlags{}, authConfig{})) + require.NoError(t, validateTargetConfiguration(GlobalFlags{URI: []string{"mongodb://localhost:27017"}}, authConfig{})) + require.NoError(t, validateTargetConfiguration(GlobalFlags{}, authConfig{ + AuthModules: map[string]authModule{"cloud_mongo": {Type: userPassAuthType}}, + })) +} + +func TestDynamicTargetFactoryRejectsUnknownModule(t *testing.T) { + t.Parallel() + + factory := newDynamicTargetFactory(GlobalFlags{}, authConfig{ + AuthModules: map[string]authModule{"cloud_mongo": {Type: userPassAuthType}}, + }, promslog.New(&promslog.Config{})) + + _, err := factory("mongo.example.com:3717", "missing") + assert.ErrorContains(t, err, "missing") +} + +func TestRedactMongoURI(t *testing.T) { + t.Parallel() + + assert.Equal(t, + "mongodb://metrics:xxxxx@mongo.example.com:3717/admin?authSource=admin", + redactMongoURI("mongodb://metrics:p%40ssword@mongo.example.com:3717/admin?authSource=admin"), + ) + assert.Equal(t, "", redactMongoURI(":")) +} + func TestParseURIList(t *testing.T) { t.Parallel() tests := map[string][]string{