diff --git a/Makefile b/Makefile index 31f77083f..046857b88 100644 --- a/Makefile +++ b/Makefile @@ -22,8 +22,9 @@ STATICCHECK_IGNORE = DOCKER_IMAGE_NAME ?= mysqld-exporter -test-docker: - @echo ">> testing docker image" +.PHONY: test-docker-single-exporter +test-docker-single-exporter: + @echo ">> testing docker image for single exporter" ./test_image.sh "$(DOCKER_IMAGE_NAME):$(DOCKER_IMAGE_TAG)" 9104 .PHONY: test-docker diff --git a/README.md b/README.md index 335bba695..b5aad7df3 100644 --- a/README.md +++ b/README.md @@ -30,15 +30,50 @@ NOTE: It is recommended to set a max connection limit for the user to avoid over ### Running -Running using an environment variable: - - export DATA_SOURCE_NAME='user:password@(hostname:3306)/' - ./mysqld_exporter +##### Single exporter mode Running using ~/.my.cnf: ./mysqld_exporter +##### Multi-target support + +This exporter supports the multi-target pattern. This allows running a single instance of this exporter for multiple MySQL targets. + +To use the multi-target functionality, send an http request to the endpoint /probe?target=foo:5432 where target is set to the DSN of the MySQL instance to scrape metrics from. + +To avoid putting sensitive information like username and password in the URL, you can have multiple configurations in `config.my-cnf` file and match it by adding `&auth_module=
` to the request. + +Sample config file for multiple configurations + + [client] + user = foo + password = foo123 + [client.servers] + user = bar + password = bar123 + +On the prometheus side you can set a scrape config as follows + + - job_name: mysql # To get metrics about the mysql exporter’s targets + params: + # Not required. Will match value to child in config file. Default value is `client`. + auth_module: client.servers + static_configs: + - targets: + # All mysql hostnames to monitor. + - server1:3306 + - server2:3306 + relabel_configs: + - source_labels: [__address__] + target_label: __param_target + - source_labels: [__param_target] + target_label: instance + - target_label: __address__ + # The mysqld_exporter host:port + replacement: localhost:9104 + +##### Flag format Example format for flags for version > 0.10.0: --collect.auto_increment.columns @@ -102,6 +137,8 @@ collect.heartbeat.utc | 5.1 | U ### General Flags Name | Description -------------------------------------------|-------------------------------------------------------------------------------------------------- +mysqld.address | Hostname and port used for connecting to MySQL server, format: `host:port`. (default: `locahost:3306`) +mysqld.username | Username to be used for connecting to MySQL Server config.my-cnf | Path to .my.cnf file to read MySQL credentials from. (default: `~/.my.cnf`) log.level | Logging verbosity (default: info) exporter.lock_wait_timeout | Set a lock_wait_timeout (in seconds) on the connection to avoid long metadata locking. (default: 2) @@ -112,6 +149,15 @@ web.listen-address | Address to listen on for web interf web.telemetry-path | Path under which to expose metrics. version | Print the version information. +### Environment Variables +Name | Description +-------------------------------------------|-------------------------------------------------------------------------------------------------- +MYSQLD_EXPORTER_PASSWORD | Password to be used for connecting to MySQL Server + +### Configuration precedence + +If you have configured cli with both `mysqld` flags and a valid configuration file, the options in the configuration file will override the flags for `client` section. + ## TLS and basic authentication The MySQLd Exporter supports TLS and basic authentication. @@ -120,12 +166,6 @@ To use TLS and/or basic authentication, you need to pass a configuration file using the `--web.config.file` parameter. The format of the file is described [in the exporter-toolkit repository](https://github.com/prometheus/exporter-toolkit/blob/master/docs/web-configuration.md). -### Setting the MySQL server's data source name - -The MySQL server's [data source name](http://en.wikipedia.org/wiki/Data_source_name) -must be set via the `DATA_SOURCE_NAME` environment variable. -The format of this variable is described at https://github.com/go-sql-driver/mysql#dsn-data-source-name. - ## Customizing Configuration for a SSL Connection If The MySQL server supports SSL, you may need to specify a CA truststore to verify the server's chain-of-trust. You may also need to specify a SSL keypair for the client side of the SSL connection. To configure the mysqld exporter to use a custom CA certificate, add the following to the mysql cnf file: @@ -141,8 +181,6 @@ ssl-key=/path/to/ssl/client/key ssl-cert=/path/to/ssl/client/cert ``` -Customizing the SSL configuration is only supported in the mysql cnf file and is not supported if you set the mysql server's data source name in the environment variable DATA_SOURCE_NAME. - ## Using Docker @@ -157,9 +195,8 @@ docker pull prom/mysqld-exporter docker run -d \ -p 9104:9104 \ --network my-mysql-network \ - -e DATA_SOURCE_NAME="user:password@(hostname:3306)/" \ prom/mysqld-exporter -``` + --config.my-cnf= ## heartbeat diff --git a/config/config.go b/config/config.go new file mode 100644 index 000000000..c0b97dae2 --- /dev/null +++ b/config/config.go @@ -0,0 +1,229 @@ +// Copyright 2022 The Prometheus Authors +// 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 config + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "net" + "os" + "sync" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + + "github.com/go-sql-driver/mysql" + "github.com/prometheus/client_golang/prometheus" + + "gopkg.in/ini.v1" +) + +var ( + configReloadSuccess = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "mysqld_exporter", + Name: "config_last_reload_successful", + Help: "Mysqld exporter config loaded successfully.", + }) + + configReloadSeconds = prometheus.NewGauge(prometheus.GaugeOpts{ + Namespace: "mysqld_exporter", + Name: "config_last_reload_success_timestamp_seconds", + Help: "Timestamp of the last successful configuration reload.", + }) + + cfg *ini.File + + opts = ini.LoadOptions{ + // Do not error on nonexistent file to allow empty string as filename input + Loose: true, + // MySQL ini file can have boolean keys. + AllowBooleanKeys: true, + } + + err error +) + +type Config struct { + Sections map[string]MySqlConfig +} + +type MySqlConfig struct { + User string `ini:"user"` + Password string `ini:"password"` + Host string `ini:"host"` + Port int `ini:"port"` + Socket string `ini:"socket"` + SslCa string `ini:"ssl-ca"` + SslCert string `ini:"ssl-cert"` + SslKey string `ini:"ssl-key"` + TlsInsecureSkipVerify bool `ini:"ssl-skip-verfication"` +} + +type MySqlConfigHandler struct { + sync.RWMutex + TlsInsecureSkipVerify bool + Config *Config +} + +func (ch *MySqlConfigHandler) GetConfig() *Config { + ch.RLock() + defer ch.RUnlock() + return ch.Config +} + +func (ch *MySqlConfigHandler) ReloadConfig(filename string, mysqldAddress string, mysqldUser string, tlsInsecureSkipVerify bool, logger log.Logger) error { + var host, port string + defer func() { + if err != nil { + configReloadSuccess.Set(0) + } else { + configReloadSuccess.Set(1) + configReloadSeconds.SetToCurrentTime() + } + }() + + if cfg, err = ini.LoadSources( + opts, + []byte("[client]\npassword = ${MYSQLD_EXPORTER_PASSWORD}\n"), + filename, + ); err != nil { + return fmt.Errorf("failed to load %s: %w", filename, err) + } + + if host, port, err = net.SplitHostPort(mysqldAddress); err != nil { + return fmt.Errorf("failed to parse address: %w", err) + } + + if clientSection := cfg.Section("client"); clientSection != nil { + if cfgHost := clientSection.Key("host"); cfgHost.String() == "" { + cfgHost.SetValue(host) + } + if cfgPort := clientSection.Key("port"); cfgPort.String() == "" { + cfgPort.SetValue(port) + } + if cfgUser := clientSection.Key("user"); cfgUser.String() == "" { + cfgUser.SetValue(mysqldUser) + } + } + + cfg.ValueMapper = os.ExpandEnv + config := &Config{} + m := make(map[string]MySqlConfig) + for _, sec := range cfg.Sections() { + sectionName := sec.Name() + + if sectionName == "DEFAULT" { + continue + } + + mysqlcfg := &MySqlConfig{ + TlsInsecureSkipVerify: tlsInsecureSkipVerify, + } + if err != nil { + level.Error(logger).Log("msg", "failed to load config", "section", sectionName, "err", err) + continue + } + + err = sec.StrictMapTo(mysqlcfg) + if err != nil { + level.Error(logger).Log("msg", "failed to parse config", "section", sectionName, "err", err) + continue + } + if err := mysqlcfg.validateConfig(); err != nil { + level.Error(logger).Log("msg", "failed to validate config", "section", sectionName, "err", err) + continue + } + + m[sectionName] = *mysqlcfg + } + config.Sections = m + if len(config.Sections) == 0 { + return fmt.Errorf("no configuration found") + } + ch.Lock() + ch.Config = config + ch.Unlock() + return nil +} + +func (m MySqlConfig) validateConfig() error { + if m.User == "" { + return fmt.Errorf("no user specified in section or parent") + } + if m.Password == "" { + return fmt.Errorf("no password specified in section or parent") + } + + return nil +} + +func (m MySqlConfig) FormDSN(target string) (string, error) { + var dsn, host, port string + + user := m.User + password := m.Password + if target == "" { + host := m.Host + port := m.Port + socket := m.Socket + if socket != "" { + dsn = fmt.Sprintf("%s:%s@unix(%s)/", user, password, socket) + } else { + dsn = fmt.Sprintf("%s:%s@tcp(%s:%d)/", user, password, host, port) + } + } else { + if host, port, err = net.SplitHostPort(target); err != nil { + return dsn, fmt.Errorf("failed to parse target: %s", err) + } + dsn = fmt.Sprintf("%s:%s@tcp(%s:%s)/", user, password, host, port) + } + + if m.SslCa != "" { + if err := m.CustomizeTLS(); err != nil { + err = fmt.Errorf("failed to register a custom TLS configuration for mysql dsn: %w", err) + return dsn, err + } + dsn = fmt.Sprintf("%s?tls=custom", dsn) + } + + return dsn, nil +} + +func (m MySqlConfig) CustomizeTLS() error { + var tlsCfg tls.Config + caBundle := x509.NewCertPool() + pemCA, err := os.ReadFile(m.SslCa) + if err != nil { + return err + } + if ok := caBundle.AppendCertsFromPEM(pemCA); ok { + tlsCfg.RootCAs = caBundle + } else { + return fmt.Errorf("failed parse pem-encoded CA certificates from %s", m.SslCa) + } + if m.SslCert != "" && m.SslKey != "" { + certPairs := make([]tls.Certificate, 0, 1) + keypair, err := tls.LoadX509KeyPair(m.SslCert, m.SslKey) + if err != nil { + return fmt.Errorf("failed to parse pem-encoded SSL cert %s or SSL key %s: %w", + m.SslCert, m.SslKey, err) + } + certPairs = append(certPairs, keypair) + tlsCfg.Certificates = certPairs + } + tlsCfg.InsecureSkipVerify = m.TlsInsecureSkipVerify + mysql.RegisterTLSConfig("custom", &tlsCfg) + return nil +} diff --git a/config/config_test.go b/config/config_test.go new file mode 100644 index 000000000..b7b1280b2 --- /dev/null +++ b/config/config_test.go @@ -0,0 +1,172 @@ +// Copyright 2022 The Prometheus Authors +// 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 config + +import ( + "fmt" + "os" + "testing" + + "github.com/go-kit/log" + + "github.com/smartystreets/goconvey/convey" +) + +func TestValidateConfig(t *testing.T) { + convey.Convey("Working config validation", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + if err := c.ReloadConfig("testdata/client.cnf", "localhost:3306", "", true, log.NewNopLogger()); err != nil { + t.Error(err) + } + + convey.Convey("Valid configuration", func() { + cfg := c.GetConfig() + convey.So(cfg.Sections, convey.ShouldContainKey, "client") + convey.So(cfg.Sections, convey.ShouldContainKey, "client.server1") + + section, ok := cfg.Sections["client"] + convey.So(ok, convey.ShouldBeTrue) + convey.So(section.User, convey.ShouldEqual, "root") + convey.So(section.Password, convey.ShouldEqual, "abc") + + childSection, ok := cfg.Sections["client.server1"] + convey.So(ok, convey.ShouldBeTrue) + convey.So(childSection.User, convey.ShouldEqual, "test") + convey.So(childSection.Password, convey.ShouldEqual, "foo") + + }) + + convey.Convey("False on non-existent section", func() { + cfg := c.GetConfig() + _, ok := cfg.Sections["fakeclient"] + convey.So(ok, convey.ShouldBeFalse) + }) + }) + + convey.Convey("Inherit from parent section", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + if err := c.ReloadConfig("testdata/child_client.cnf", "localhost:3306", "", true, log.NewNopLogger()); err != nil { + t.Error(err) + } + cfg := c.GetConfig() + section, _ := cfg.Sections["client.server1"] + convey.So(section.Password, convey.ShouldEqual, "abc") + }) + + convey.Convey("Environment variable / CLI flags", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + os.Setenv("MYSQLD_EXPORTER_PASSWORD", "supersecretpassword") + if err := c.ReloadConfig("", "testhost:5000", "testuser", true, log.NewNopLogger()); err != nil { + t.Error(err) + } + + cfg := c.GetConfig() + section := cfg.Sections["client"] + convey.So(section.Host, convey.ShouldEqual, "testhost") + convey.So(section.Port, convey.ShouldEqual, 5000) + convey.So(section.User, convey.ShouldEqual, "testuser") + convey.So(section.Password, convey.ShouldEqual, "supersecretpassword") + }) + + convey.Convey("Environment variable / CLI flags error without port", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + os.Setenv("MYSQLD_EXPORTER_PASSWORD", "supersecretpassword") + err := c.ReloadConfig("", "testhost", "testuser", true, log.NewNopLogger()) + convey.So( + err, + convey.ShouldBeError, + ) + }) + + convey.Convey("Config file precedence over environment variables", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + os.Setenv("MYSQLD_EXPORTER_PASSWORD", "supersecretpassword") + if err := c.ReloadConfig("testdata/client.cnf", "localhost:3306", "fakeuser", true, log.NewNopLogger()); err != nil { + t.Error(err) + } + + cfg := c.GetConfig() + section := cfg.Sections["client"] + convey.So(section.User, convey.ShouldEqual, "root") + convey.So(section.Password, convey.ShouldEqual, "abc") + }) + + convey.Convey("Client without user", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + os.Clearenv() + err := c.ReloadConfig("testdata/missing_user.cnf", "localhost:3306", "", true, log.NewNopLogger()) + convey.So( + err, + convey.ShouldResemble, + fmt.Errorf("no configuration found"), + ) + }) + + convey.Convey("Client without password", t, func() { + c := MySqlConfigHandler{ + Config: &Config{}, + } + os.Clearenv() + err := c.ReloadConfig("testdata/missing_password.cnf", "localhost:3306", "", true, log.NewNopLogger()) + convey.So( + err, + convey.ShouldResemble, + fmt.Errorf("no configuration found"), + ) + }) +} + +func TestFormDSN(t *testing.T) { + var ( + c = MySqlConfigHandler{ + Config: &Config{}, + } + err error + dsn string + ) + + convey.Convey("Host exporter dsn", t, func() { + if err := c.ReloadConfig("testdata/client.cnf", "localhost:3306", "", true, log.NewNopLogger()); err != nil { + t.Error(err) + } + convey.Convey("Default Client", func() { + cfg := c.GetConfig() + section, _ := cfg.Sections["client"] + if dsn, err = section.FormDSN(""); err != nil { + t.Error(err) + } + convey.So(dsn, convey.ShouldEqual, "root:abc@tcp(server2:3306)/") + }) + convey.Convey("Target specific with explicit port", func() { + cfg := c.GetConfig() + section, _ := cfg.Sections["client.server1"] + if dsn, err = section.FormDSN("server1:5000"); err != nil { + t.Error(err) + } + convey.So(dsn, convey.ShouldEqual, "test:foo@tcp(server1:5000)/") + }) + }) +} diff --git a/config/testdata/child_client.cnf b/config/testdata/child_client.cnf new file mode 100644 index 000000000..cd779742f --- /dev/null +++ b/config/testdata/child_client.cnf @@ -0,0 +1,5 @@ +[client] +user = root +password = abc +[client.server1] +user = root diff --git a/config/testdata/client.cnf b/config/testdata/client.cnf new file mode 100644 index 000000000..3416acca0 --- /dev/null +++ b/config/testdata/client.cnf @@ -0,0 +1,7 @@ +[client] +user = root +password = abc +host = server2 +[client.server1] +user = test +password = foo diff --git a/config/testdata/missing_password.cnf b/config/testdata/missing_password.cnf new file mode 100644 index 000000000..04e48bec9 --- /dev/null +++ b/config/testdata/missing_password.cnf @@ -0,0 +1,2 @@ +[client] +user = abc diff --git a/config/testdata/missing_user.cnf b/config/testdata/missing_user.cnf new file mode 100644 index 000000000..dc78f1b91 --- /dev/null +++ b/config/testdata/missing_user.cnf @@ -0,0 +1,2 @@ +[client] +password = abc diff --git a/mysqld_exporter.go b/mysqld_exporter.go index ba5b054e1..1fe5d4194 100644 --- a/mysqld_exporter.go +++ b/mysqld_exporter.go @@ -15,19 +15,14 @@ package main import ( "context" - "crypto/tls" - "crypto/x509" - "fmt" "net/http" "os" "path" "strconv" - "strings" "time" "github.com/go-kit/log" "github.com/go-kit/log/level" - "github.com/go-sql-driver/mysql" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" "github.com/prometheus/common/promlog" @@ -36,9 +31,9 @@ import ( "github.com/prometheus/exporter-toolkit/web" webflag "github.com/prometheus/exporter-toolkit/web/kingpinflag" "gopkg.in/alecthomas/kingpin.v2" - "gopkg.in/ini.v1" "github.com/prometheus/mysqld_exporter/collector" + "github.com/prometheus/mysqld_exporter/config" ) var ( @@ -59,11 +54,21 @@ var ( "config.my-cnf", "Path to .my.cnf file to read MySQL credentials from.", ).Default(path.Join(os.Getenv("HOME"), ".my.cnf")).String() + mysqldAddress = kingpin.Flag( + "mysqld.address", + "Address to use for connecting to MySQL", + ).Default("localhost:3306").String() + mysqldUser = kingpin.Flag( + "mysqld.username", + "Hostname to use for connecting to MySQL", + ).String() tlsInsecureSkipVerify = kingpin.Flag( "tls.insecure-skip-verify", "Ignore certificate and server verification when using a tls connection.", ).Bool() - dsn string + c = config.MySqlConfigHandler{ + Config: &config.Config{}, + } ) // scrapers lists all possible collection methods and if they should be enabled by default. @@ -104,76 +109,23 @@ var scrapers = map[collector.Scraper]bool{ collector.ScrapeReplicaHost{}: false, } -func parseMycnf(config interface{}) (string, error) { - var dsn string - opts := ini.LoadOptions{ - // MySQL ini file can have boolean keys. - AllowBooleanKeys: true, - } - cfg, err := ini.LoadSources(opts, config) - if err != nil { - return dsn, fmt.Errorf("failed reading ini file: %s", err) - } - user := cfg.Section("client").Key("user").String() - password := cfg.Section("client").Key("password").String() - if user == "" { - return dsn, fmt.Errorf("no user specified under [client] in %s", config) - } - host := cfg.Section("client").Key("host").MustString("localhost") - port := cfg.Section("client").Key("port").MustUint(3306) - socket := cfg.Section("client").Key("socket").String() - sslCA := cfg.Section("client").Key("ssl-ca").String() - sslCert := cfg.Section("client").Key("ssl-cert").String() - sslKey := cfg.Section("client").Key("ssl-key").String() - passwordPart := "" - if password != "" { - passwordPart = ":" + password - } else { - if sslKey == "" { - return dsn, fmt.Errorf("password or ssl-key should be specified under [client] in %s", config) - } - } - if socket != "" { - dsn = fmt.Sprintf("%s%s@unix(%s)/", user, passwordPart, socket) - } else { - dsn = fmt.Sprintf("%s%s@tcp(%s:%d)/", user, passwordPart, host, port) - } - if sslCA != "" { - if tlsErr := customizeTLS(sslCA, sslCert, sslKey); tlsErr != nil { - tlsErr = fmt.Errorf("failed to register a custom TLS configuration for mysql dsn: %s", tlsErr) - return dsn, tlsErr - } - dsn = fmt.Sprintf("%s?tls=custom", dsn) - } +func filterScrapers(scrapers []collector.Scraper, collectParams []string) []collector.Scraper { + filteredScrapers := scrapers - return dsn, nil -} + // Check if we have some "collect[]" query parameters. + if len(collectParams) > 0 { + filters := make(map[string]bool) + for _, param := range collectParams { + filters[param] = true + } -func customizeTLS(sslCA string, sslCert string, sslKey string) error { - var tlsCfg tls.Config - caBundle := x509.NewCertPool() - pemCA, err := os.ReadFile(sslCA) - if err != nil { - return err - } - if ok := caBundle.AppendCertsFromPEM(pemCA); ok { - tlsCfg.RootCAs = caBundle - } else { - return fmt.Errorf("failed parse pem-encoded CA certificates from %s", sslCA) - } - if sslCert != "" && sslKey != "" { - certPairs := make([]tls.Certificate, 0, 1) - keypair, err := tls.LoadX509KeyPair(sslCert, sslKey) - if err != nil { - return fmt.Errorf("failed to parse pem-encoded SSL cert %s or SSL key %s: %s", - sslCert, sslKey, err) + for _, scraper := range scrapers { + if filters[scraper.Name()] { + filteredScrapers = append(filteredScrapers, scraper) + } } - certPairs = append(certPairs, keypair) - tlsCfg.Certificates = certPairs } - tlsCfg.InsecureSkipVerify = *tlsInsecureSkipVerify - mysql.RegisterTLSConfig("custom", &tlsCfg) - return nil + return filteredScrapers } func init() { @@ -182,8 +134,20 @@ func init() { func newHandler(metrics collector.Metrics, scrapers []collector.Scraper, logger log.Logger) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - filteredScrapers := scrapers - params := r.URL.Query()["collect[]"] + var dsn string + var err error + + cfg := c.GetConfig() + cfgsection, ok := cfg.Sections["client"] + if !ok { + level.Error(logger).Log("msg", "Failed to parse section [client] from config file", "err", err) + } + if dsn, err = cfgsection.FormDSN(""); err != nil { + level.Error(logger).Log("msg", "Failed to form dsn from section [client]", "err", err) + } + + collect := r.URL.Query()["collect[]"] + // Use request context for cancellation when connection gets closed. ctx := r.Context() // If a timeout is configured via the Prometheus header, add it to the context. @@ -207,24 +171,11 @@ func newHandler(metrics collector.Metrics, scrapers []collector.Scraper, logger r = r.WithContext(ctx) } } - level.Debug(logger).Log("msg", "collect[] params", "params", strings.Join(params, ",")) - - // Check if we have some "collect[]" query parameters. - if len(params) > 0 { - filters := make(map[string]bool) - for _, param := range params { - filters[param] = true - } - filteredScrapers = nil - for _, scraper := range scrapers { - if filters[scraper.Name()] { - filteredScrapers = append(filteredScrapers, scraper) - } - } - } + filteredScrapers := filterScrapers(scrapers, collect) registry := prometheus.NewRegistry() + registry.MustRegister(collector.New(ctx, dsn, metrics, filteredScrapers, logger)) gatherers := prometheus.Gatherers{ @@ -276,13 +227,10 @@ func main() { level.Info(logger).Log("msg", "Starting mysqld_exporter", "version", version.Info()) level.Info(logger).Log("msg", "Build context", version.BuildContext()) - dsn = os.Getenv("DATA_SOURCE_NAME") - if len(dsn) == 0 { - var err error - if dsn, err = parseMycnf(*configMycnf); err != nil { - level.Info(logger).Log("msg", "Error parsing my.cnf", "file", *configMycnf, "err", err) - os.Exit(1) - } + var err error + if err = c.ReloadConfig(*configMycnf, *mysqldAddress, *mysqldUser, *tlsInsecureSkipVerify, logger); err != nil { + level.Info(logger).Log("msg", "Error parsing host config", "file", *configMycnf, "err", err) + os.Exit(1) } // Register only scrapers enabled by flag. @@ -298,6 +246,7 @@ func main() { http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { w.Write(landingPage) }) + http.HandleFunc("/probe", handleProbe(collector.NewMetrics(), enabledScrapers, logger)) level.Info(logger).Log("msg", "Listening on address", "address", *listenAddress) srv := &http.Server{Addr: *listenAddress} diff --git a/mysqld_exporter_test.go b/mysqld_exporter_test.go index 5a8ace727..df7393746 100644 --- a/mysqld_exporter_test.go +++ b/mysqld_exporter_test.go @@ -28,124 +28,8 @@ import ( "syscall" "testing" "time" - - "github.com/smartystreets/goconvey/convey" ) -func TestParseMycnf(t *testing.T) { - const ( - tcpConfig = ` - [client] - user = root - password = abc123 - ` - tcpConfig2 = ` - [client] - user = root - password = abc123 - port = 3308 - ` - clientAuthConfig = ` - [client] - user = root - port = 3308 - ssl-ca = ca.crt - ssl-cert = tls.crt - ssl-key = tls.key - ` - socketConfig = ` - [client] - user = user - password = pass - socket = /var/lib/mysql/mysql.sock - ` - socketConfig2 = ` - [client] - user = dude - password = nopassword - # host and port will not be used because of socket presence - host = 1.2.3.4 - port = 3307 - socket = /var/lib/mysql/mysql.sock - ` - remoteConfig = ` - [client] - user = dude - password = nopassword - host = 1.2.3.4 - port = 3307 - ` - ignoreBooleanKeys = ` - [client] - user = root - password = abc123 - - [mysql] - skip-auto-rehash - ` - badConfig = ` - [client] - user = root - ` - badConfig2 = ` - [client] - password = abc123 - socket = /var/lib/mysql/mysql.sock - ` - badConfig3 = ` - [hello] - world = ismine - ` - badConfig4 = `[hello` - ) - convey.Convey("Various .my.cnf configurations", t, func() { - convey.Convey("Local tcp connection", func() { - dsn, _ := parseMycnf([]byte(tcpConfig)) - convey.So(dsn, convey.ShouldEqual, "root:abc123@tcp(localhost:3306)/") - }) - convey.Convey("Local tcp connection on non-default port", func() { - dsn, _ := parseMycnf([]byte(tcpConfig2)) - convey.So(dsn, convey.ShouldEqual, "root:abc123@tcp(localhost:3308)/") - }) - convey.Convey("Authentication with client certificate and no password", func() { - dsn, _ := parseMycnf([]byte(clientAuthConfig)) - convey.So(dsn, convey.ShouldEqual, "root@tcp(localhost:3308)/") - }) - convey.Convey("Socket connection", func() { - dsn, _ := parseMycnf([]byte(socketConfig)) - convey.So(dsn, convey.ShouldEqual, "user:pass@unix(/var/lib/mysql/mysql.sock)/") - }) - convey.Convey("Socket connection ignoring defined host", func() { - dsn, _ := parseMycnf([]byte(socketConfig2)) - convey.So(dsn, convey.ShouldEqual, "dude:nopassword@unix(/var/lib/mysql/mysql.sock)/") - }) - convey.Convey("Remote connection", func() { - dsn, _ := parseMycnf([]byte(remoteConfig)) - convey.So(dsn, convey.ShouldEqual, "dude:nopassword@tcp(1.2.3.4:3307)/") - }) - convey.Convey("Ignore boolean keys", func() { - dsn, _ := parseMycnf([]byte(ignoreBooleanKeys)) - convey.So(dsn, convey.ShouldEqual, "root:abc123@tcp(localhost:3306)/") - }) - convey.Convey("Missed user", func() { - _, err := parseMycnf([]byte(badConfig)) - convey.So(err, convey.ShouldBeError, fmt.Errorf("password or ssl-key should be specified under [client] in %s", badConfig)) - }) - convey.Convey("Missed password", func() { - _, err := parseMycnf([]byte(badConfig2)) - convey.So(err, convey.ShouldBeError, fmt.Errorf("no user specified under [client] in %s", badConfig2)) - }) - convey.Convey("No [client] section", func() { - _, err := parseMycnf([]byte(badConfig3)) - convey.So(err, convey.ShouldBeError, fmt.Errorf("no user specified under [client] in %s", badConfig3)) - }) - convey.Convey("Invalid config", func() { - _, err := parseMycnf([]byte(badConfig4)) - convey.So(err, convey.ShouldBeError, fmt.Errorf("failed reading ini file: unclosed section: %s", badConfig4)) - }) - }) -} - // bin stores information about path of executable and attached port type bin struct { path string @@ -195,7 +79,8 @@ func TestBin(t *testing.T) { } tests := []func(*testing.T, bin){ - testLandingPage, + testLanding, + testProbe, } portStart := 56000 @@ -216,7 +101,7 @@ func TestBin(t *testing.T) { }) } -func testLandingPage(t *testing.T, data bin) { +func testLanding(t *testing.T, data bin) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() @@ -225,8 +110,8 @@ func testLandingPage(t *testing.T, data bin) { ctx, data.path, "--web.listen-address", fmt.Sprintf(":%d", data.port), + "--config.my-cnf=test_exporter.cnf", ) - cmd.Env = append(os.Environ(), "DATA_SOURCE_NAME=127.0.0.1:3306") if err := cmd.Start(); err != nil { t.Fatal(err) } @@ -254,6 +139,38 @@ func testLandingPage(t *testing.T, data bin) { } } +func testProbe(t *testing.T, data bin) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Run exporter. + cmd := exec.CommandContext( + ctx, + data.path, + "--web.listen-address", fmt.Sprintf(":%d", data.port), + "--config.my-cnf=test_exporter.cnf", + ) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + defer cmd.Wait() + defer cmd.Process.Kill() + + // Get the main page. + urlToGet := fmt.Sprintf("http://127.0.0.1:%d/probe", data.port) + body, err := waitForBody(urlToGet) + if err != nil { + t.Fatal(err) + } + got := strings.TrimSpace(string(body)) + + expected := `target is required` + + if got != expected { + t.Fatalf("got '%s' but expected '%s'", got, expected) + } +} + // waitForBody is a helper function which makes http calls until http server is up // and then returns body of the successful call. func waitForBody(urlToGet string) (body []byte, err error) { diff --git a/probe.go b/probe.go new file mode 100644 index 000000000..47990216c --- /dev/null +++ b/probe.go @@ -0,0 +1,76 @@ +// Copyright 2022 The Prometheus Authors +// 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 + +import ( + "fmt" + "net/http" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/mysqld_exporter/collector" +) + +func handleProbe(metrics collector.Metrics, scrapers []collector.Scraper, logger log.Logger) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + var dsn, authModule string + var err error + + ctx := r.Context() + params := r.URL.Query() + target := params.Get("target") + if target == "" { + http.Error(w, "target is required", http.StatusBadRequest) + return + } + collectParams := r.URL.Query()["collect[]"] + + if authModule = params.Get("auth_module"); authModule == "" { + authModule = "client" + } + + cfg := c.GetConfig() + cfgsection, ok := cfg.Sections[authModule] + if !ok { + level.Error(logger).Log("msg", fmt.Sprintf("Failed to parse section [%s] from config file", authModule), "err", err) + http.Error(w, fmt.Sprintf("Error parsing config section [%s]", authModule), http.StatusBadRequest) + } + if dsn, err = cfgsection.FormDSN(target); err != nil { + level.Error(logger).Log("msg", fmt.Sprintf("Failed to form dsn from section [%s]", authModule), "err", err) + http.Error(w, fmt.Sprintf("Error forming dsn from config section [%s]", authModule), http.StatusBadRequest) + } + + probeSuccessGauge := prometheus.NewGauge(prometheus.GaugeOpts{ + Name: "probe_success", + Help: "Displays whether or not the probe was a success", + }) + + filteredScrapers := filterScrapers(scrapers, collectParams) + + registry := prometheus.NewRegistry() + registry.MustRegister(probeSuccessGauge) + registry.MustRegister(collector.New(ctx, dsn, metrics, filteredScrapers, logger)) + + if err != nil { + probeSuccessGauge.Set(1) + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) + h.ServeHTTP(w, r) + } +} diff --git a/test_exporter.cnf b/test_exporter.cnf new file mode 100644 index 000000000..f71c7e5d3 --- /dev/null +++ b/test_exporter.cnf @@ -0,0 +1,9 @@ +[client] +host=localhost +port=3306 +socket=/var/run/mysqld/mysqld.sock +user=foo +password=bar +[client.server1] +user = bar +password = bar123 diff --git a/test_image.sh b/test_image.sh index c3cfe278e..99f2efe9e 100755 --- a/test_image.sh +++ b/test_image.sh @@ -15,12 +15,12 @@ wait_start() { sleep 1 fi done - + exit 1 } docker_start() { - container_id=$(docker run -d --network mysql-test -e DATA_SOURCE_NAME="root:secret@(mysql-test:3306)/" -p "${port}":"${port}" "${docker_image}") + container_id=$(docker run -d --network mysql-test -p "${port}":"${port}" "${docker_image}" --config.my-cnf=test_exporter.cnf) } docker_cleanup() {