diff --git a/README.md b/README.md index 84f8e7589..244e14ae1 100644 --- a/README.md +++ b/README.md @@ -104,6 +104,19 @@ If your URI is prefixed by mongodb:// or mongodb+srv:// schema, any host not pre --mongodb.uri=mongodb+srv://user:pass@host1:27017,host2:27017,host3:27017/admin,mongodb://user2:pass2@host4:27018/admin ``` +You can use the --split-cluster option to split all cluster nodes into separate targets. This mode is useful when cluster nodes are defined as SRV records and the mongodb_exporter is running with mongodb+srv domain specified. In this case SRV records will be queried upon mongodb_exporter start and each cluster node can be queried using the **target** parameter of multitarget endpoint. + +#### Overall targets request endpoint + +There is an overall targets endpoint **/scrapeall** that queries all the targets in one request. It can be used to store multiple node metrics without separate target requests. In this case, each node metric will have a **instance** label containing the node name as a host:port pair (or just host if no port was not specified). For example, for mongodb_exporter running with the options: +``` +--mongodb.uri="mongodb://host1:27015,host2:27016" --split-cluster=true +``` +we get metrics like this: +``` +mongodb_up{instance="host1:27015"} 1 +mongodb_up{instance="host2:27016"} 1 +``` #### Enabling collstats metrics gathering `--mongodb.collstats-colls` receives a list of databases and collections to monitor using collstats. diff --git a/REFERENCE.md b/REFERENCE.md index 6ebd8a1f2..f7d8f01d8 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -11,6 +11,7 @@ | --[no-]mongodb.direct-connect | Whether or not a direct connect should be made. Direct connections are not valid if multiple hosts are specified or an SRV URI is used | | | --[no-]mongodb.global-conn-pool | Use global connection pool instead of creating new pool for each http request | | | --mongodb.uri | MongoDB connection URI ($MONGODB_URI) | --mongodb.uri=mongodb://user:pass@127.0.0.1:27017/admin?ssl=true | +| --split-cluster | Whether to treat cluster members from the connection URI as separate targets | | --web.listen-address | Address to listen on for web interface and telemetry | --web.listen-address=":9216" | | --web.telemetry-path | Metrics expose path | --web.telemetry-path="/metrics" | | --web.config | Path to the file having Prometheus TLS config for basic auth | --web.config=STRING | diff --git a/exporter/exporter.go b/exporter/exporter.go index 5f991d288..75a166061 100644 --- a/exporter/exporter.go +++ b/exporter/exporter.go @@ -80,7 +80,8 @@ type Opts struct { IndexStatsCollections []string Logger *logrus.Logger - URI string + URI string + NodeName string } var ( @@ -290,7 +291,7 @@ func (e *Exporter) getClient(ctx context.Context) (*mongo.Client, error) { func (e *Exporter) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { seconds, err := strconv.Atoi(r.Header.Get("X-Prometheus-Scrape-Timeout-Seconds")) - // To support also older ones vmagents. + // To support older ones vmagents. if err != nil { seconds = 10 } @@ -300,40 +301,7 @@ func (e *Exporter) Handler() http.Handler { ctx, cancel := context.WithTimeout(r.Context(), time.Duration(seconds)*time.Second) defer cancel() - filters := r.URL.Query()["collect[]"] - - requestOpts := Opts{} - - if len(filters) == 0 { - requestOpts = *e.opts - } - - for _, filter := range filters { - switch filter { - case "diagnosticdata": - requestOpts.EnableDiagnosticData = true - case "replicasetstatus": - requestOpts.EnableReplicasetStatus = true - case "dbstats": - requestOpts.EnableDBStats = true - case "topmetrics": - requestOpts.EnableTopMetrics = true - case "currentopmetrics": - requestOpts.EnableCurrentopMetrics = true - case "indexstats": - requestOpts.EnableIndexStats = true - case "collstats": - requestOpts.EnableCollStats = true - case "profile": - requestOpts.EnableProfile = true - case "shards": - requestOpts.EnableShards = true - case "fcv": - requestOpts.EnableFCV = true - case "pbm": - requestOpts.EnablePBMMetrics = true - } - } + requestOpts := GetRequestOpts(r.URL.Query()["collect[]"], e.opts) client, err = e.getClient(ctx) if err != nil { @@ -386,6 +354,44 @@ func (e *Exporter) Handler() http.Handler { }) } +// GetRequestOpts makes exporter.Opts structure from request filters and default options. +func GetRequestOpts(filters []string, defaultOpts *Opts) Opts { + requestOpts := Opts{} + + if len(filters) == 0 { + requestOpts = *defaultOpts + } + + for _, filter := range filters { + switch filter { + case "diagnosticdata": + requestOpts.EnableDiagnosticData = true + case "replicasetstatus": + requestOpts.EnableReplicasetStatus = true + case "dbstats": + requestOpts.EnableDBStats = true + case "topmetrics": + requestOpts.EnableTopMetrics = true + case "currentopmetrics": + requestOpts.EnableCurrentopMetrics = true + case "indexstats": + requestOpts.EnableIndexStats = true + case "collstats": + requestOpts.EnableCollStats = true + case "profile": + requestOpts.EnableProfile = true + case "shards": + requestOpts.EnableShards = true + case "fcv": + requestOpts.EnableFCV = true + case "pbm": + requestOpts.EnablePBMMetrics = true + } + } + + return requestOpts +} + func connect(ctx context.Context, opts *Opts) (*mongo.Client, error) { clientOpts, err := dsn_fix.ClientOptionsForDSN(opts.URI) if err != nil { diff --git a/exporter/gatherer_wrapper.go b/exporter/gatherer_wrapper.go new file mode 100644 index 000000000..65a38d67b --- /dev/null +++ b/exporter/gatherer_wrapper.go @@ -0,0 +1,59 @@ +// mongodb_exporter +// Copyright (C) 2017 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 exporter + +import ( + "github.com/pkg/errors" + "github.com/prometheus/client_golang/prometheus" + io_prometheus_client "github.com/prometheus/client_model/go" +) + +// GathererWrapped is a wrapper for prometheus.Gatherer that adds labels to all metrics. +type GathererWrapped struct { + originalGatherer prometheus.Gatherer + labels prometheus.Labels +} + +// NewGathererWrapper creates a new GathererWrapped with the given Gatherer and additional labels. +func NewGathererWrapper(gs prometheus.Gatherer, labels prometheus.Labels) *GathererWrapped { + return &GathererWrapped{ + originalGatherer: gs, + labels: labels, + } +} + +// Gather implements prometheus.Gatherer interface. +func (g *GathererWrapped) Gather() ([]*io_prometheus_client.MetricFamily, error) { + metrics, err := g.originalGatherer.Gather() + if err != nil { + return nil, errors.Wrap(err, "failed to gather metrics") + } + + for _, metric := range metrics { + for _, m := range metric.GetMetric() { + for k, v := range g.labels { + v := v + k := k + m.Label = append(m.Label, &io_prometheus_client.LabelPair{ + Name: &k, + Value: &v, + }) + } + } + } + + return metrics, nil +} diff --git a/exporter/multi_target_test.go b/exporter/multi_target_test.go index 3065ad2ec..f04a5a287 100644 --- a/exporter/multi_target_test.go +++ b/exporter/multi_target_test.go @@ -17,7 +17,11 @@ package exporter import ( "fmt" + "io" "net" + "net/http" + "net/http/httptest" + "regexp" "testing" "github.com/sirupsen/logrus" @@ -70,3 +74,61 @@ func TestMultiTarget(t *testing.T) { assert.HTTPBodyContains(t, multiTargetHandler(serverMap), "GET", fmt.Sprintf("?target=%s", opt.URI), nil, expected[sn]) } } + +func TestOverallHandler(t *testing.T) { + t.Parallel() + + opts := []*Opts{ + { + NodeName: "standalone", + URI: fmt.Sprintf("mongodb://127.0.0.1:%s", tu.GetenvDefault("TEST_MONGODB_STANDALONE_PORT", "27017")), + DirectConnect: true, + ConnectTimeoutMS: 1000, + }, + { + NodeName: "s1", + URI: fmt.Sprintf("mongodb://127.0.0.1:%s", tu.GetenvDefault("TEST_MONGODB_S1_PRIMARY_PORT", "17001")), + DirectConnect: true, + ConnectTimeoutMS: 1000, + }, + { + NodeName: "s2", + URI: fmt.Sprintf("mongodb://127.0.0.1:%s", tu.GetenvDefault("TEST_MONGODB_S2_PRIMARY_PORT", "17004")), + DirectConnect: true, + ConnectTimeoutMS: 1000, + }, + { + NodeName: "s3", + URI: "mongodb://127.0.0.1:12345", + DirectConnect: true, + ConnectTimeoutMS: 1000, + }, + } + expected := []*regexp.Regexp{ + regexp.MustCompile(`mongodb_up{[^\}]*instance="standalone"[^\}]*} 1\n`), + regexp.MustCompile(`mongodb_up{[^\}]*instance="s1"[^\}]*} 1\n`), + regexp.MustCompile(`mongodb_up{[^\}]*instance="s2"[^\}]*} 1\n`), + regexp.MustCompile(`mongodb_up{[^\}]*instance="s3"[^\}]*} 0\n`), + } + exporters := make([]*Exporter, len(opts)) + + logger := logrus.New() + + for i, opt := range opts { + exporters[i] = New(opt) + } + + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodGet, "/", nil) + OverallTargetsHandler(exporters, logger)(rr, req) + res := rr.Result() + resBody, _ := io.ReadAll(res.Body) + err := res.Body.Close() + assert.NoError(t, err) + + assert.Equal(t, http.StatusOK, res.StatusCode) + + for _, expected := range expected { + assert.Regexp(t, expected, string(resBody)) + } +} diff --git a/exporter/seedlist.go b/exporter/seedlist.go new file mode 100644 index 000000000..343907696 --- /dev/null +++ b/exporter/seedlist.go @@ -0,0 +1,88 @@ +// mongodb_exporter +// Copyright (C) 2017 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 exporter + +import ( + "net" + "net/url" + "strconv" + "strings" + + "github.com/sirupsen/logrus" +) + +// GetSeedListFromSRV converts mongodb+srv URI to flat connection string. +func GetSeedListFromSRV(uri string, log *logrus.Logger) string { + uriParsed, err := url.Parse(uri) + if err != nil { + log.Fatalf("Failed to parse URI %s: %v", uri, err) + } + + cname, srvRecords, err := net.LookupSRV("mongodb", "tcp", uriParsed.Hostname()) + if err != nil { + log.Errorf("Failed to lookup SRV records for %s: %v", uri, err) + return uri + } + + if len(srvRecords) == 0 { + log.Errorf("No SRV records found for %s", uri) + return uri + } + + queryString := uriParsed.RawQuery + + txtRecords, err := net.LookupTXT(uriParsed.Hostname()) + if err != nil { + log.Errorf("Failed to lookup TXT records for %s: %v", cname, err) + } + if len(txtRecords) > 1 { + log.Errorf("Multiple TXT records found for %s, thus were not applied", cname) + } + if len(txtRecords) == 1 { + // We take connection parameters from the TXT record + uriParams, err := url.ParseQuery(txtRecords[0]) + if err != nil { + log.Errorf("Failed to parse TXT record %s: %v", txtRecords[0], err) + } else { + // Override connection parameters with ones from URI query string + for p, v := range uriParsed.Query() { + uriParams[p] = v + } + queryString = uriParams.Encode() + } + } + + // Build final connection URI + servers := make([]string, len(srvRecords)) + for i, srv := range srvRecords { + servers[i] = net.JoinHostPort(strings.TrimSuffix(srv.Target, "."), strconv.FormatUint(uint64(srv.Port), 10)) + } + uri = "mongodb://" + if uriParsed.User != nil { + uri += uriParsed.User.String() + "@" + } + uri += strings.Join(servers, ",") + if uriParsed.Path != "" { + uri += uriParsed.Path + } else { + uri += "/" + } + if queryString != "" { + uri += "?" + queryString + } + + return uri +} diff --git a/exporter/seedlist_test.go b/exporter/seedlist_test.go new file mode 100644 index 000000000..b236cae0a --- /dev/null +++ b/exporter/seedlist_test.go @@ -0,0 +1,52 @@ +// mongodb_exporter +// Copyright (C) 2017 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 exporter + +import ( + "net" + "testing" + + "github.com/foxcpp/go-mockdns" + "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" + + "github.com/percona/mongodb_exporter/internal/tu" +) + +func TestGetSeedListFromSRV(t *testing.T) { + // Can't run in parallel because it patches the net.DefaultResolver + + log := logrus.New() + srv := tu.SetupFakeResolver() + + defer func(t *testing.T) { + t.Helper() + err := srv.Close() + assert.NoError(t, err) + }(t) + defer mockdns.UnpatchNet(net.DefaultResolver) + + tests := map[string]string{ + "mongodb+srv://server.example.com": "mongodb://mongo1.example.com:17001,mongo2.example.com:17002,mongo3.example.com:17003/?authSource=admin", + "mongodb+srv://user:pass@server.example.com?replicaSet=rs0&authSource=db0": "mongodb://user:pass@mongo1.example.com:17001,mongo2.example.com:17002,mongo3.example.com:17003/?authSource=db0&replicaSet=rs0", + "mongodb+srv://unexistent.com": "mongodb+srv://unexistent.com", + } + + for uri, expected := range tests { + actual := GetSeedListFromSRV(uri, log) + assert.Equal(t, expected, actual) + } +} diff --git a/exporter/server.go b/exporter/server.go index 6788acb31..c92a211b9 100644 --- a/exporter/server.go +++ b/exporter/server.go @@ -16,12 +16,16 @@ package exporter import ( + "context" "net/http" "net/url" "os" + "strconv" "strings" "time" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/common/promlog" "github.com/prometheus/exporter-toolkit/web" "github.com/sirupsen/logrus" @@ -32,10 +36,12 @@ type ServerMap map[string]http.Handler // ServerOpts is the options for the main http handler type ServerOpts struct { - Path string - MultiTargetPath string - WebListenAddress string - TLSConfigPath string + Path string + MultiTargetPath string + OverallTargetPath string + WebListenAddress string + TLSConfigPath string + DisableDefaultRegistry bool } // Runs the main web-server @@ -51,6 +57,7 @@ func RunWebServer(opts *ServerOpts, exporters []*Exporter, log *logrus.Logger) { 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(` @@ -97,6 +104,69 @@ func multiTargetHandler(serverMap ServerMap) http.HandlerFunc { } } +// OverallTargetsHandler is a handler to scrape all the targets in one request. +// Adds instance label to each metric. +func OverallTargetsHandler(exporters []*Exporter, logger *logrus.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + seconds, err := strconv.Atoi(r.Header.Get("X-Prometheus-Scrape-Timeout-Seconds")) + // To support older ones vmagents. + if err != nil { + seconds = 10 + logger.Debug("Can't get X-Prometheus-Scrape-Timeout-Seconds header, using default value 10") + } + + var gatherers prometheus.Gatherers + gatherers = append(gatherers, prometheus.DefaultGatherer) + + filters := r.URL.Query()["collect[]"] + + for _, e := range exporters { + ctx, cancel := context.WithTimeout(r.Context(), time.Duration(seconds-e.opts.TimeoutOffset)*time.Second) + defer cancel() + + requestOpts := GetRequestOpts(filters, e.opts) + + client, err := e.getClient(ctx) + if err != nil { + e.logger.Errorf("Cannot connect to MongoDB: %v", err) + } + + // Close client after usage. + if !e.opts.GlobalConnPool { + defer func() { + if client != nil { + err := client.Disconnect(ctx) + if err != nil { + logger.Errorf("Cannot disconnect client: %v", err) + } + } + }() + } + + var ti *topologyInfo + if client != nil { + // Topology can change between requests, so we need to get it every time. + ti = newTopologyInfo(ctx, client, e.logger) + } + + hostlabels := prometheus.Labels{ + "instance": e.opts.NodeName, + } + + registry := NewGathererWrapper(e.makeRegistry(ctx, client, ti, requestOpts), hostlabels) + gatherers = append(gatherers, registry) + } + + // Delegate http serving to Prometheus client library, which will call collector.Collect. + h := promhttp.HandlerFor(gatherers, promhttp.HandlerOpts{ + ErrorHandling: promhttp.ContinueOnError, + ErrorLog: logger, + }) + + h.ServeHTTP(w, r) + } +} + func buildServerMap(exporters []*Exporter, log *logrus.Logger) ServerMap { servers := make(ServerMap, len(exporters)) for _, e := range exporters { diff --git a/exporter/top_collector.go b/exporter/top_collector.go index 562a28ec6..cd99023e9 100644 --- a/exporter/top_collector.go +++ b/exporter/top_collector.go @@ -34,7 +34,7 @@ type topCollector struct { topologyInfo labelsGetter } -var ErrInvalidOrMissingTotalsEntry = fmt.Errorf("Invalid or misssing totals entry in top results") +var ErrInvalidOrMissingTotalsEntry = fmt.Errorf("invalid or misssing totals entry in top results") func newTopCollector(ctx context.Context, client *mongo.Client, logger *logrus.Logger, compatible bool, topology labelsGetter, diff --git a/exporter/v1_compatibility_test.go b/exporter/v1_compatibility_test.go index 0afc3bdc3..da1150c0c 100644 --- a/exporter/v1_compatibility_test.go +++ b/exporter/v1_compatibility_test.go @@ -108,10 +108,9 @@ func TestAddLocksMetrics(t *testing.T) { err = json.Unmarshal(buf, &m) assert.NoError(t, err) - var metrics []prometheus.Metric logger := logrus.New() logger.SetLevel(logrus.DebugLevel) - metrics = locksMetrics(logger.WithField("component", "test"), m) + metrics := locksMetrics(logger.WithField("component", "test"), m) desc := make([]string, 0, len(metrics)) for _, metric := range metrics { diff --git a/go.mod b/go.mod index 2f9a6bbcc..d085130e5 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,8 @@ require ( go.mongodb.org/mongo-driver v1.17.0 ) +require github.com/foxcpp/go-mockdns v1.1.0 + require github.com/hashicorp/go-version v1.7.0 require github.com/percona/percona-backup-mongodb v1.8.1-0.20240814130653-5285f7975ff6 @@ -41,6 +43,7 @@ require ( github.com/jpillora/backoff v1.0.0 // indirect github.com/klauspost/compress v1.17.8 // indirect github.com/klauspost/pgzip v1.2.6 // indirect + github.com/miekg/dns v1.1.57 // indirect github.com/minio/minio-go v6.0.14+incompatible // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mongodb/mongo-tools v0.0.0-20240723193119-837c2bc263f4 // indirect @@ -64,6 +67,7 @@ require ( golang.org/x/sys v0.23.0 // indirect golang.org/x/term v0.23.0 // indirect golang.org/x/text v0.17.0 // indirect + golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/protobuf v1.34.2 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/go.sum b/go.sum index 1cd3dbe24..ae1104055 100644 --- a/go.sum +++ b/go.sum @@ -31,6 +31,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dnaeon/go-vcr v1.2.0 h1:zHCHvJYTMh1N7xnV7zf1m1GPBF9Ad0Jk/whtQ1663qI= github.com/dnaeon/go-vcr v1.2.0/go.mod h1:R4UdLID7HZT3taECzJs4YgbbH6PIGXB6W/sc5OLb6RQ= +github.com/foxcpp/go-mockdns v1.1.0 h1:jI0rD8M0wuYAxL7r/ynTrCQQq0BVqfB99Vgk7DlmewI= +github.com/foxcpp/go-mockdns v1.1.0/go.mod h1:IhLeSFGed3mJIAXPH2aiRQB+kqz7oqu8ld2qVbOu7Wk= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A= @@ -74,6 +76,8 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= +github.com/miekg/dns v1.1.57 h1:Jzi7ApEIzwEPLHWRcafCN9LZSBbqQpxjt/wpgvg7wcM= +github.com/miekg/dns v1.1.57/go.mod h1:uqRjCRUuEAA6qsOiJvDd+CFo/vW+y5WR6SNmHE55hZk= github.com/minio/minio-go v6.0.14+incompatible h1:fnV+GD28LeqdN6vT2XdGKW8Qe/IfjJDswNVuni6km9o= github.com/minio/minio-go v6.0.14+incompatible/go.mod h1:7guKYtitv8dktvNUGrhzmNlA5wrAABTQXCoesZdFQO8= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -133,22 +137,37 @@ go.mongodb.org/mongo-driver v1.17.0 h1:Hp4q2MCjvY19ViwimTs00wHi7G4yzxh4/2+nTx8r4 go.mongodb.org/mongo-driver v1.17.0/go.mod h1:wwWm/+BuOddhcq3n68LKRmgk2wXzmF6s0SFOa0GINL4= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= +golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4= +golang.org/x/crypto v0.15.0/go.mod h1:4ChreQoLWfG3xLDer1WdlH5NdlQ3+mwnQq1YTKY+72g= golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= golang.org/x/exp v0.0.0-20240529005216-23cca8864a10 h1:vpzMC/iZhYFAjJzHU0Cfuq+w1vLLsF2vLkDrPjzKYck= golang.org/x/exp v0.0.0-20240529005216-23cca8864a10/go.mod h1:XtvwrStGgqGPLc4cjQfWqZHG1YFdYs6swckp8vpsjnc= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= +golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= +golang.org/x/mod v0.14.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/mod v0.19.0 h1:fEdghXQSo20giMthA7cd28ZC+jts4amQ3YMXiP5oMQ8= golang.org/x/mod v0.19.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= +golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= +golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= +golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= +golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE= +golang.org/x/net v0.18.0/go.mod h1:/czyP5RqHAH4odGYxBJ1qz0+CE5WZ+2j1YgoEo8F2jQ= golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= golang.org/x/oauth2 v0.21.0 h1:tsimM75w1tF/uws5rbeHzIWxEqElMehnc+iW793zsZs= golang.org/x/oauth2 v0.21.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.4.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= +golang.org/x/sync v0.5.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.8.0 h1:3NFvSEYkUoMifnESzZl15y791HH1qU2xm6eCJU5ZPXQ= golang.org/x/sync v0.8.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= @@ -158,21 +177,40 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.14.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.23.0 h1:YfKFowiIMvtgl1UERQoTPPToxltDeZfbj4H7dVUCwmM= golang.org/x/sys v0.23.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= +golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= +golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= +golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U= +golang.org/x/term v0.14.0/go.mod h1:TySc+nGkYR6qt8km8wUhuFRTVSMIX3XPR58y2lC8vww= golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= +golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= +golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= +golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= +golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= +golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= +golang.org/x/tools v0.15.0/go.mod h1:hpksKq4dtpQWS1uQ61JkdqWM3LscIS6Slf+VVkm+wQk= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= +golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= diff --git a/internal/tu/testutils.go b/internal/tu/testutils.go index a8f2466b4..d26d62859 100644 --- a/internal/tu/testutils.go +++ b/internal/tu/testutils.go @@ -24,10 +24,12 @@ import ( "os" "os/exec" "path/filepath" + "strconv" "strings" "testing" "time" + "github.com/foxcpp/go-mockdns" "github.com/pkg/errors" "github.com/stretchr/testify/require" "go.mongodb.org/mongo-driver/bson" @@ -196,3 +198,54 @@ func PortForContainer(name string) (string, error) { return ports[0].HostPort, nil } + +// SetupFakeResolver sets up Fake DNS server to resolve SRV records. +func SetupFakeResolver() *mockdns.Server { + p1, err1 := strconv.ParseInt(GetenvDefault("TEST_MONGODB_S1_PRIMARY_PORT", "17001"), 10, 64) + p2, err2 := strconv.ParseInt(GetenvDefault("TEST_MONGODB_S1_SECONDARY1_PORT", "17002"), 10, 64) + p3, err3 := strconv.ParseInt(GetenvDefault("TEST_MONGODB_S1_SECONDARY2_PORT", "17003"), 10, 64) + + if err1 != nil || err2 != nil || err3 != nil { + panic("Invalid ports") + } + + testZone := map[string]mockdns.Zone{ + "_mongodb._tcp.server.example.com.": { + SRV: []net.SRV{ + { + Target: "mongo1.example.com.", + Port: uint16(p1), + }, + { + Target: "mongo2.example.com.", + Port: uint16(p2), + }, + { + Target: "mongo3.example.com.", + Port: uint16(p3), + }, + }, + }, + "server.example.com.": { + TXT: []string{"authSource=admin"}, + A: []string{"1.2.3.4"}, + }, + "mongo1.example.com.": { + A: []string{"127.0.0.1"}, + }, + "mongo2.example.com.": { + A: []string{"127.0.0.1"}, + }, + "mongo3.example.com.": { + A: []string{"127.0.0.1"}, + }, + "unexistent.com.": { + A: []string{"127.0.0.1"}, + }, + } + + srv, _ := mockdns.NewServer(testZone, true) + srv.PatchNet(net.DefaultResolver) + + return srv +} diff --git a/main.go b/main.go index ffaf60003..02bcbc01c 100644 --- a/main.go +++ b/main.go @@ -17,6 +17,8 @@ package main import ( "fmt" + "net" + "net/url" "regexp" "strings" @@ -76,6 +78,7 @@ type GlobalFlags struct { DiscoveringMode bool `name:"discovering-mode" help:"Enable autodiscover collections" negatable:""` CompatibleMode bool `name:"compatible-mode" help:"Enable old mongodb-exporter compatible metrics" negatable:""` Version bool `name:"version" help:"Show version and exit"` + SplitCluster bool `name:"split-cluster" help:"Treat each node in cluster as a separate target" negatable:"" default:"false"` } func main() { @@ -126,10 +129,11 @@ func main() { } serverOpts := &exporter.ServerOpts{ - Path: opts.WebTelemetryPath, - MultiTargetPath: "/scrape", - WebListenAddress: opts.WebListenAddress, - TLSConfigPath: opts.TLSConfigPath, + Path: opts.WebTelemetryPath, + MultiTargetPath: "/scrape", + OverallTargetPath: "/scrapeall", + WebListenAddress: opts.WebListenAddress, + TLSConfigPath: opts.TLSConfigPath, } exporter.RunWebServer(serverOpts, buildServers(opts, log), log) } @@ -138,6 +142,14 @@ func buildExporter(opts GlobalFlags, uri string, log *logrus.Logger) *exporter.E uri = buildURI(uri, opts.User, opts.Password) log.Debugf("Connection URI: %s", uri) + uriParsed, _ := url.Parse(uri) + var nodeName string + if uriParsed.Port() != "" { + nodeName = net.JoinHostPort(uriParsed.Hostname(), uriParsed.Port()) + } else { + nodeName = uriParsed.Host + } + exporterOpts := &exporter.Opts{ CollStatsNamespaces: strings.Split(opts.CollStatsNamespaces, ","), CompatibleMode: opts.CompatibleMode, @@ -145,6 +157,7 @@ func buildExporter(opts GlobalFlags, uri string, log *logrus.Logger) *exporter.E IndexStatsCollections: strings.Split(opts.IndexStatsCollections, ","), Logger: log, URI: uri, + NodeName: nodeName, GlobalConnPool: opts.GlobalConnPool, DirectConnect: opts.DirectConnect, ConnectTimeoutMS: opts.ConnectTimeoutMS, @@ -177,34 +190,47 @@ func buildExporter(opts GlobalFlags, uri string, log *logrus.Logger) *exporter.E return e } -func buildServers(opts GlobalFlags, log *logrus.Logger) []*exporter.Exporter { - URIs := parseURIList(opts.URI) +func buildServers(opts GlobalFlags, logger *logrus.Logger) []*exporter.Exporter { + URIs := parseURIList(opts.URI, logger, opts.SplitCluster) servers := make([]*exporter.Exporter, len(URIs)) for serverIdx := range URIs { - servers[serverIdx] = buildExporter(opts, URIs[serverIdx], log) + servers[serverIdx] = buildExporter(opts, URIs[serverIdx], logger) } return servers } -func parseURIList(uriList []string) []string { +func parseURIList(uriList []string, logger *logrus.Logger, splitCluster bool) []string { var URIs []string - // If server URI is prefixed with mongodb, then every next URI in line not prefixed with mongodb is a part of cluster - // Otherwise treat it as a standalone server + // If server URI is prefixed with mongodb scheme string, then every next URI in + // line not prefixed with mongodb scheme string is a part of cluster. Otherwise + // treat it as a standalone server realURI := "" matchRegexp := regexp.MustCompile(`^mongodb(\+srv)?://`) for _, URI := range uriList { - if matchRegexp.MatchString(URI) { + matches := matchRegexp.FindStringSubmatch(URI) + if matches != nil { if realURI != "" { + // Add the previous host buffer to the url list as we met the scheme part URIs = append(URIs, realURI) + realURI = "" + } + if matches[1] == "" { + realURI = URI + } else { + // There can be only one host in SRV connection string + if splitCluster { + // In splitCluster mode we get srv connection string from SRV recors + URI = exporter.GetSeedListFromSRV(URI, logger) + } + URIs = append(URIs, URI) } - realURI = URI } else { if realURI == "" { URIs = append(URIs, "mongodb://"+URI) } else { - realURI = realURI + "," + URI + realURI += "," + URI } } } @@ -212,6 +238,31 @@ func parseURIList(uriList []string) []string { URIs = append(URIs, realURI) } + if splitCluster { + // In this mode we split cluster strings into separate targets + separateURIs := []string{} + for _, hosturl := range URIs { + urlParsed, err := url.Parse(hosturl) + if err != nil { + logger.Fatal(fmt.Sprintf("Failed to parse URI %s: %v", hosturl, err)) + } + for _, host := range strings.Split(urlParsed.Host, ",") { + targetURI := "mongodb://" + if urlParsed.User != nil { + targetURI += urlParsed.User.String() + "@" + } + targetURI += host + if urlParsed.Path != "" { + targetURI += urlParsed.Path + } + if urlParsed.RawQuery != "" { + targetURI += "?" + urlParsed.RawQuery + } + separateURIs = append(separateURIs, targetURI) + } + } + return separateURIs + } return URIs } diff --git a/main_test.go b/main_test.go index fae2a0151..b716a298c 100644 --- a/main_test.go +++ b/main_test.go @@ -16,25 +16,86 @@ package main import ( + "net" "strings" "testing" + "github.com/foxcpp/go-mockdns" "github.com/sirupsen/logrus" "github.com/stretchr/testify/assert" + + "github.com/percona/mongodb_exporter/internal/tu" ) func TestParseURIList(t *testing.T) { t.Parallel() tests := map[string][]string{ "mongodb://server": {"mongodb://server"}, - "mongodb+srv://server1,server2,mongodb://server3,server4,server5": {"mongodb+srv://server1,server2", "mongodb://server3,server4,server5"}, - "server1": {"mongodb://server1"}, - "server1,server2,server3": {"mongodb://server1", "mongodb://server2", "mongodb://server3"}, - "mongodb.server,server2": {"mongodb://mongodb.server", "mongodb://server2"}, - "standalone,mongodb://server1,server2,mongodb+srv://server3,server4,mongodb://server5": {"mongodb://standalone", "mongodb://server1,server2", "mongodb+srv://server3,server4", "mongodb://server5"}, + "mongodb+srv://server1,server2,mongodb://server3,server4,server5": { + "mongodb+srv://server1", + "mongodb://server2", + "mongodb://server3,server4,server5", + }, + "server1": {"mongodb://server1"}, + "server1,server2,server3": { + "mongodb://server1", + "mongodb://server2", + "mongodb://server3", + }, + "mongodb.server,server2": { + "mongodb://mongodb.server", + "mongodb://server2", + }, + "standalone,mongodb://server1,server2,mongodb+srv://server3,server4,mongodb://server5": { + "mongodb://standalone", + "mongodb://server1,server2", + "mongodb+srv://server3", + "mongodb://server4", + "mongodb://server5", + }, + } + logger := logrus.New() + for test, expected := range tests { + actual := parseURIList(strings.Split(test, ","), logger, false) + assert.Equal(t, expected, actual) + } +} + +func TestSplitCluster(t *testing.T) { + // Can't run in parallel because it patches the net.DefaultResolver + + tests := map[string][]string{ + "mongodb://server": {"mongodb://server"}, + "mongodb://user:pass@server1,server2/admin?replicaSet=rs1,mongodb://server3,server4,server5": { + "mongodb://user:pass@server1/admin?replicaSet=rs1", + "mongodb://user:pass@server2/admin?replicaSet=rs1", + "mongodb://server3", + "mongodb://server4", + "mongodb://server5", + }, + "mongodb://server1,mongodb://user:pass@server2,server3?arg=1&arg2=2,mongodb+srv://user:pass@server.example.com/db?replicaSet=rs1": { + "mongodb://server1", + "mongodb://user:pass@server2?arg=1&arg2=2", + "mongodb://user:pass@server3?arg=1&arg2=2", + "mongodb://user:pass@mongo1.example.com:17001/db?authSource=admin&replicaSet=rs1", + "mongodb://user:pass@mongo2.example.com:17002/db?authSource=admin&replicaSet=rs1", + "mongodb://user:pass@mongo3.example.com:17003/db?authSource=admin&replicaSet=rs1", + }, } + + logger := logrus.New() + + srv := tu.SetupFakeResolver() + + defer func(t *testing.T) { + t.Helper() + err := srv.Close() + assert.NoError(t, err) + }(t) + defer mockdns.UnpatchNet(net.DefaultResolver) + for test, expected := range tests { - actual := parseURIList(strings.Split(test, ",")) + actual := parseURIList(strings.Split(test, ","), logger, true) assert.Equal(t, expected, actual) } }