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:
parent
c7ab57968a
commit
593b0095a5
5
Makefile
5
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
|
||||
|
65
README.md
65
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 <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 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=<path_to_cnf>
|
||||
|
||||
## heartbeat
|
||||
|
||||
|
229
config/config.go
Normal file
229
config/config.go
Normal 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
172
config/config_test.go
Normal 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
5
config/testdata/child_client.cnf
vendored
Normal file
@ -0,0 +1,5 @@
|
||||
[client]
|
||||
user = root
|
||||
password = abc
|
||||
[client.server1]
|
||||
user = root
|
7
config/testdata/client.cnf
vendored
Normal file
7
config/testdata/client.cnf
vendored
Normal 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
2
config/testdata/missing_password.cnf
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
[client]
|
||||
user = abc
|
2
config/testdata/missing_user.cnf
vendored
Normal file
2
config/testdata/missing_user.cnf
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
[client]
|
||||
password = abc
|
@ -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}
|
||||
|
@ -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
76
probe.go
Normal 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
9
test_exporter.cnf
Normal 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
|
@ -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() {
|
||||
|
Loading…
x
Reference in New Issue
Block a user