Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions REFERENCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
142 changes: 142 additions & 0 deletions auth_config.go
Original file line number Diff line number Diff line change
@@ -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()
}
87 changes: 86 additions & 1 deletion exporter/multi_target_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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])
}
}

Expand Down
Loading