diff --git a/README.md b/README.md index 6b65778ec..6e5620eca 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/main.go b/main.go index 69c893918..d92ce0b9f 100644 --- a/main.go +++ b/main.go @@ -21,6 +21,8 @@ import ( "log/slog" "net" "net/url" + "os" + "path/filepath" "regexp" "strings" @@ -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"` @@ -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{ @@ -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) diff --git a/main_test.go b/main_test.go index 3d54f866e..31e441e30 100644 --- a/main_test.go +++ b/main_test.go @@ -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" ) @@ -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