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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,22 @@ 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
```

The `MONGODB_USER_FILE` and `MONGODB_PASSWORD_FILE` environment variables can
instead point to files containing the username and password. File values take
precedence over credentials supplied directly. This supports Docker secrets,
for example:
```yml
command:
- --mongodb.uri=mongodb://${MONGO_IP}:${MONGO_PORT}/?authSource=admin
- --collect-all
environment:
MONGODB_USER_FILE: /run/secrets/mongo_usr
MONGODB_PASSWORD_FILE: /run/secrets/mongo_pwd
secrets:
- mongo_usr
- mongo_pwd
```

#### 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
43 changes: 43 additions & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import (
"log/slog"
"net"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"

Expand All @@ -40,7 +42,9 @@ var (
// GlobalFlags has command line flags to configure the exporter.
type GlobalFlags struct {
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"`
UserFile string `env:"MONGODB_USER_FILE" help:"Path to a file containing the monitor user" name:"mongodb.user-file" type:"path"`
Password string `env:"MONGODB_PASSWORD" help:"monitor user password" name:"mongodb.password" placeholder:"monitorPassword"`
PasswordFile string `env:"MONGODB_PASSWORD_FILE" help:"Path to a file containing the monitor user password" name:"mongodb.password-file" type:"path"`
CollStatsNamespaces string `help:"List of comma separared databases.collections to get $collStats" name:"mongodb.collstats-colls" placeholder:"db1,db2.col2"`
IndexStatsCollections string `help:"List of comma separared databases.collections to get $indexStats" name:"mongodb.indexstats-colls" placeholder:"db1.col1,db2.col2"`
URI []string `env:"MONGODB_URI" help:"MongoDB connection URI" name:"mongodb.uri" placeholder:"mongodb://user:pass@127.0.0.1:27017/admin?ssl=true"`
Expand Down Expand Up @@ -107,6 +111,11 @@ func main() {
return
}

err := loadCredentialFiles(&opts)
if err != nil {
ctx.Fatalf("Failed to load MongoDB credentials: %v", err)
}

logLevel := promslog.NewLevel()
_ = logLevel.Set(opts.LogLevel)
logger := promslog.New(&promslog.Config{
Expand Down Expand Up @@ -138,6 +147,40 @@ func main() {
exporter.RunWebServer(serverOpts, buildServers(opts, logger), logger)
}

func loadCredentialFiles(opts *GlobalFlags) error {
user, err := readCredentialFile(opts.UserFile)
if err != nil {
return fmt.Errorf("failed to read MongoDB user file %q: %w", opts.UserFile, err)
}

password, err := readCredentialFile(opts.PasswordFile)
if err != nil {
return fmt.Errorf("failed to read MongoDB password file %q: %w", opts.PasswordFile, err)
}

if opts.UserFile != "" {
opts.User = user
}
if opts.PasswordFile != "" {
opts.Password = password
}

return nil
}

func readCredentialFile(path string) (string, error) {
if path == "" {
return "", nil
}

contents, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return "", fmt.Errorf("read credential file: %w", err)
}

return strings.TrimSpace(string(contents)), 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)
Expand Down
104 changes: 104 additions & 0 deletions main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,12 +17,15 @@ 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"
)
Expand Down Expand Up @@ -61,6 +64,107 @@ func TestParseURIList(t *testing.T) {
}
}

func TestLoadCredentialFiles(t *testing.T) {
t.Parallel()

dir := t.TempDir()
userFile := filepath.Join(dir, "user")
passwordFile := filepath.Join(dir, "password")
require.NoError(t, os.WriteFile(userFile, []byte(" file-user\n"), 0o600))
require.NoError(t, os.WriteFile(passwordFile, []byte("\tfile-password\r\n"), 0o600))

tests := []struct {
name string
opts GlobalFlags
expectedUser string
expectedPassword string
}{
{
name: "files override direct values",
opts: GlobalFlags{
User: "direct-user",
UserFile: userFile,
Password: "direct-password",
PasswordFile: passwordFile,
},
expectedUser: "file-user",
expectedPassword: "file-password",
},
{
name: "user file only",
opts: GlobalFlags{
User: "direct-user",
UserFile: userFile,
Password: "direct-password",
},
expectedUser: "file-user",
expectedPassword: "direct-password",
},
{
name: "password file only",
opts: GlobalFlags{
User: "direct-user",
Password: "direct-password",
PasswordFile: passwordFile,
},
expectedUser: "direct-user",
expectedPassword: "file-password",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

opts := test.opts
require.NoError(t, loadCredentialFiles(&opts))
assert.Equal(t, test.expectedUser, opts.User)
assert.Equal(t, test.expectedPassword, opts.Password)
})
}
}

func TestLoadCredentialFilesNoFiles(t *testing.T) {
t.Parallel()

opts := GlobalFlags{User: "direct-user", Password: "direct-password"}
require.NoError(t, loadCredentialFiles(&opts))
assert.Equal(t, "direct-user", opts.User)
assert.Equal(t, "direct-password", opts.Password)
}

func TestLoadCredentialFilesErrors(t *testing.T) {
t.Parallel()

missingFile := filepath.Join(t.TempDir(), "missing")
tests := []struct {
name string
opts GlobalFlags
expectedError string
}{
{
name: "missing user file",
opts: GlobalFlags{UserFile: missingFile},
expectedError: "failed to read MongoDB user file",
},
{
name: "missing password file",
opts: GlobalFlags{PasswordFile: missingFile},
expectedError: "failed to read MongoDB password file",
},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
t.Parallel()

err := loadCredentialFiles(&test.opts)
require.ErrorContains(t, err, test.expectedError)
assert.ErrorContains(t, err, missingFile)
})
}
}

func TestSplitCluster(t *testing.T) {
// Can't run in parallel because it patches the net.DefaultResolver

Expand Down