1
0
mirror of https://github.com/prometheus/mysqld_exporter.git synced 2025-04-18 09:24:02 +03:00

Support for scraping multiple mysqld hosts (#651)

Add multi-target exporter scrape support.

Breaking change:
`DATA_SOURCE_NAME` is now removed. Local configuration is now supplied by `MYSQLD_EXPORTER_PASSWORD` and command line arguments.

Signed-off-by: Mattias Ängehov <mattias.angehov@castoredc.com>
This commit is contained in:
Mattias Ängehov 2022-09-01 16:48:32 +02:00 committed by GitHub
parent c7ab57968a
commit 593b0095a5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 641 additions and 235 deletions

View File

@ -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

View File

@ -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 <flags>
##### Single exporter mode
Running using ~/.my.cnf:
./mysqld_exporter <flags>
##### 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=<section>` 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 exporters 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=<path_to_cnf>
## heartbeat

229
config/config.go Normal file
View File

@ -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
}

172
config/config_test.go Normal file
View File

@ -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)/")
})
})
}

5
config/testdata/child_client.cnf vendored Normal file
View File

@ -0,0 +1,5 @@
[client]
user = root
password = abc
[client.server1]
user = root

7
config/testdata/client.cnf vendored Normal file
View File

@ -0,0 +1,7 @@
[client]
user = root
password = abc
host = server2
[client.server1]
user = test
password = foo

2
config/testdata/missing_password.cnf vendored Normal file
View File

@ -0,0 +1,2 @@
[client]
user = abc

2
config/testdata/missing_user.cnf vendored Normal file
View File

@ -0,0 +1,2 @@
[client]
password = abc

View File

@ -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
}
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)
// Check if we have some "collect[]" query parameters.
if len(collectParams) > 0 {
filters := make(map[string]bool)
for _, param := range collectParams {
filters[param] = true
}
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}

View File

@ -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) {

76
probe.go Normal file
View File

@ -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)
}
}

9
test_exporter.cnf Normal file
View File

@ -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

View File

@ -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() {