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(` -