From c4cbed1b63b98ea0cb37d0a27eec4340acd7499e Mon Sep 17 00:00:00 2001 From: Maikel Poot Date: Fri, 7 Feb 2025 14:48:37 +0100 Subject: [PATCH] feat: Implement multi-target mode Signed-off-by: Maikel Poot --- README.md | 42 +++++++ collector.go | 26 +++- config.go | 122 +++++++++++++++++++ config.yaml | 37 ++++++ config_test.go | 136 +++++++++++++++++++++ credentials.go | 207 ++++++++++++++++++++++++++++++++ credentials_test.go | 219 ++++++++++++++++++++++++++++++++++ go.mod | 3 + go.sum | 4 + pgbouncer_exporter.go | 136 +++++++++++++++++---- testdata/client.crt | 3 + testdata/client.key | 1 + testdata/config-legacy.yaml | 8 ++ testdata/config.yaml | 14 +++ testdata/dsn-legacy-off.yaml | 4 + testdata/duplicate_creds.yaml | 9 ++ testdata/invalid_creds.yaml | 13 ++ testdata/parse_error.yaml | 8 ++ 18 files changed, 966 insertions(+), 26 deletions(-) create mode 100644 config.go create mode 100644 config.yaml create mode 100644 config_test.go create mode 100644 credentials.go create mode 100644 credentials_test.go create mode 100644 testdata/client.crt create mode 100644 testdata/client.key create mode 100644 testdata/config-legacy.yaml create mode 100644 testdata/config.yaml create mode 100644 testdata/dsn-legacy-off.yaml create mode 100644 testdata/duplicate_creds.yaml create mode 100644 testdata/invalid_creds.yaml create mode 100644 testdata/parse_error.yaml diff --git a/README.md b/README.md index c63cf39..44047e5 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,52 @@ Exports metrics at `9127/metrics` make build ./pgbouncer_exporter +## Exporter configuration + +### Command line flags To see all available configuration flags: ./pgbouncer_exporter -h +### Config file +The exporter can be configured using a config file using the `--config.file` flag. +When using a config file the default operation changes from single-target to multi-target mode. Can be set by `legacy_mode` + +For more information about the possibilities and requirements see [the example config.yaml file within this repo](config.yaml) + + ./pgbouncer_exporter --config.file config.yaml + +### Multi-Target mode + +In multi-target mode this exporter adheres to the https://prometheus.io/docs/guides/multi-target-exporter/ pattern. +The probe endpoints accepts 2 parameters: + +- `dsn`: the postgresql connection string +- `cred`: credential reference to credentials stored in the config file. + +When `cred` is used the dsn will be updated with the credential values from the config file. +If the DSN and credentials config define the same parameter the latter takes precedence. +With the example below the exporter will scrape `"postgres://username:password@localhost:6543/pgbouncer?sslmode=disable"`. + +*mtls with client certificates is also supported through the use of credentials config* + +`prometheus.yaml`: +```yaml +- job_name: pgbouncer-exporter + metrics_path: /probe + params: + dsn: "postgres://localhost:6543/pgbouncer?sslmode=disable" + cred: "monitoring" +``` + +`config.yaml` +```yaml +credentials: + - key: monitoring + username: username + password: password +``` + ## PGBouncer configuration The pgbouncer\_exporter requires a configuration change to pgbouncer to ignore a PostgreSQL driver connection parameter. In the `pgbouncer.ini` please include this option: diff --git a/collector.go b/collector.go index fb527dd..344c190 100644 --- a/collector.go +++ b/collector.go @@ -133,9 +133,16 @@ var ( ) ) -func NewExporter(connectionString string, namespace string, logger *slog.Logger) *Exporter { +func NewExporter(connectionString string, namespace string, logger *slog.Logger, mustConnect bool) *Exporter { - db, err := getDB(connectionString) + var db *sql.DB + var err error + + if mustConnect { + db, err = getDBWithTest(connectionString) + } else { + db, err = getDB(connectionString) + } if err != nil { logger.Error("error setting up DB connection", "err", err.Error()) @@ -337,15 +344,22 @@ func getDB(conn string) (*sql.DB, error) { if err != nil { return nil, err } + + db.SetMaxOpenConns(1) + db.SetMaxIdleConns(1) + + return db, nil +} +func getDBWithTest(conn string) (*sql.DB, error) { + db, err := getDB(conn) + if err != nil { + return nil, err + } rows, err := db.Query("SHOW STATS") if err != nil { return nil, fmt.Errorf("error pinging pgbouncer: %w", err) } defer rows.Close() - - db.SetMaxOpenConns(1) - db.SetMaxIdleConns(1) - return db, nil } diff --git a/config.go b/config.go new file mode 100644 index 0000000..b82def7 --- /dev/null +++ b/config.go @@ -0,0 +1,122 @@ +// Copyright 2020 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 ( + "errors" + "fmt" + "gopkg.in/yaml.v3" + "os" +) + +var ( + ErrorNoConfigFileGiven = errors.New("File path cannot be an empty string") +) + +func index2human(index int) string { + + // Reduce value to last digit, with the exception for 11th,12th and 13th. + // 22 => 2 => 22nd + selector := index + if index >= 14 { + selector = index % 10 + } + + switch selector { + case 1: + return fmt.Sprintf("%dst", index) + case 2: + return fmt.Sprintf("%dnd", index) + case 3: + return fmt.Sprintf("%drd", index) + default: + return fmt.Sprintf("%dth", index) + } +} + +type DuplicateCredentialsKeyError struct { + message string + index int + first int +} + +func (e DuplicateCredentialsKeyError) Error() string { + return fmt.Sprintf("%s credential has duplicate key '%s' (already defined by %s credential)", index2human(e.index), e.message, index2human(e.first)) +} + +func NewDefaultConfig() *Config { + return &Config{ + MetricsPath: "/metrics", + ProbePath: "/probe", + Credentials: make([]Credentials, 0), + LegacyMode: true, + MustConnectOnStartup: true, + } +} + +func (c *Config) ReadFromFile(path string) error { + var err error + var data []byte + if path == "" { + return ErrorNoConfigFileGiven + } + // Turn off legacyMode + c.LegacyMode = false + + data, err = os.ReadFile(path) + if err != nil { + return err + } + + err = yaml.Unmarshal(data, c) + if err != nil { + return err + } + var credErr CredentialsErrorInterface + keyCount := map[string]int{} + for i, credential := range c.Credentials { + if credErr = credential.Validate(); credErr != nil { + credErr.SetIndex(i + 1) + return credErr + } + if first, ok := keyCount[credential.GetKey()]; !ok { + keyCount[credential.GetKey()] = i + } else { + return &DuplicateCredentialsKeyError{credential.GetKey(), i + 1, first + 1} + } + } + + return nil +} + +type Config struct { + MetricsPath string `yaml:"metrics_path"` + ProbePath string `yaml:"probe_path"` + Credentials []Credentials `yaml:"credentials"` + LegacyMode bool `yaml:"legacy_mode"` + DSN string `yaml:"dsn"` + PidFile string `yaml:"pid_file"` + MustConnectOnStartup bool `yaml:"must_connect_on_startup"` +} + +func (c *Config) GetCredentials(key string) (Credentials, error) { + for _, cred := range c.Credentials { + if cred.GetKey() == key { + return cred, nil + } + } + + return Credentials{}, fmt.Errorf("credential %s not found", key) + +} diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..5cef3d9 --- /dev/null +++ b/config.yaml @@ -0,0 +1,37 @@ +## metrics_path: /path/for/metrics; Default: /metrics +metrics_path: /metrics +## probe_path: /path/for/probe; Default: /probe +probe_path: /probe + +## Turn on legacy mode, where the exporter scrapes a configured endpoint and exposes the metrics on the metrics_path +## Defaults to false when using config file +legacy_mode: false +## dsn connection uri when using legacy_mode +dsn: postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable +## pgbouncer pid file when using legacy_mode +pid_file: +## must_connect_on_startup: true|false; Default: true +## If true the exporter will fail to start if any connection fails to connect within the startup fase. +## If false the exporter will start even if some connections fail. +must_connect_on_startup: false + +## Credentials for multi-target usage +credentials: + - key: monitoring # Optional, if key is not the username is used as credential key + username: username + password: password + + + - username: sslstats # Optional, if key is not the username is used as credential key + ## SSL Connection parameters + ## for more info see the corresponding ssl* parameters in + ## https://www.postgresql.org/docs/current/libpq-connect.html#LIBPQ-CONNECT-SSLMODE + ssl: + mode: "verify-full" # optional, Defaults to `prefer` + cert: testdata/client.crt # path to the certificate file + key: testdata/client.key # path to the private key file + password: "" # required if private key is password protected + compression: 1 # optional: 1 = enable, 0 = disable, default to `0` + negotiation: "direct" # optional, defaults to `postgres` + cert_mode: "require" # optional, defaults to `allow` + root_cert: "system" # optional, path to allowed server CA's, or `system` for system's ca store diff --git a/config_test.go b/config_test.go new file mode 100644 index 0000000..ee0a338 --- /dev/null +++ b/config_test.go @@ -0,0 +1,136 @@ +// Copyright 2024 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 langu +package main + +import ( + "errors" + "fmt" + "github.com/google/go-cmp/cmp" + "io/fs" + "strings" + "testing" +) + +func TestDefaultConfig(t *testing.T) { + + config := NewDefaultConfig() + + MetricsPathWant := "/metrics" + if config.MetricsPath != MetricsPathWant { + t.Errorf("MetricsPath does not match. Want: %v, Got: %v", MetricsPathWant, config.MetricsPath) + } + + ProbePathWant := "/probe" + if config.ProbePath != ProbePathWant { + t.Errorf("ProbePath does not match. Want: %v, Got: %v", ProbePathWant, config.ProbePath) + } + +} + +func TestUnHappyFileConfig(t *testing.T) { + + config := NewDefaultConfig() + var err error + + err = config.ReadFromFile("") + if errors.Is(err, ErrorNoConfigFileGiven) == false { + t.Errorf("config.ReadFromFile should return ErrorNoConfigFileGiven error. Got: %v", err) + } + + err = config.ReadFromFile("./testdata/i-do-not-exist.yaml") + if errors.Is(err, fs.ErrNotExist) == false { + t.Errorf("config.ReadFromFile should return fs.ErrNotExist error. Got: %v", err) + } + + err = config.ReadFromFile("./testdata/parse_error.yaml") + if err != nil && strings.Contains(err.Error(), "yaml: line") == false { + t.Errorf("config.ReadFromFile should return yaml parse error. Got: %v", err) + } + + err = config.ReadFromFile("./testdata/duplicate_creds.yaml") + var dcke *DuplicateCredentialsKeyError + if errors.As(err, &dcke) == false { + t.Errorf("config.ReadFromFile should return DuplicateCredentialsKeyError error. Got: %v", err) + } + + err = config.ReadFromFile("./testdata/invalid_creds.yaml") + var ce *CredentialsError + if errors.As(err, &ce) == false { + t.Errorf("config.ReadFromFile should return CredentialsError error. Got: %v", err) + } else if err.(*CredentialsError).field != "ssl.key" { + t.Errorf("config.ReadFromFile should return CredentialsError for field key. Got: %v", err) + } + + for i, v := range map[int]string{2: "2nd", 3: "3rd", 4: "4th", 15: "15th", 20: "20th", 30: "30th"} { + err = DuplicateCredentialsKeyError{message: "test", index: i, first: 1} + want := fmt.Sprintf("%s credential has duplicate key 'test' (already defined by 1st credential)", v) + if err.Error() != want { + t.Errorf("DuplicateCredentialsKeyError did not return expected string. Want %v, Got: %v", want, err.Error()) + } + } + +} + +func TestFileConfig(t *testing.T) { + + config := NewDefaultConfig() + var err error + + err = config.ReadFromFile("./testdata/config.yaml") + if err != nil { + t.Errorf("config.ReadFromFile() should not throw an error: %v", err) + } + + MetricsPathWant := "/prom" + if config.MetricsPath != MetricsPathWant { + t.Errorf("MetricsPath does not match. Want: %v, Got: %v", MetricsPathWant, config.MetricsPath) + } + + ProbePathWant := "/data" + if config.ProbePath != ProbePathWant { + t.Errorf("ProbePath does not match. Want: %v, Got: %v", ProbePathWant, config.ProbePath) + } + + CredKeyWant := "cred_c" + cred, err := config.GetCredentials(CredKeyWant) + if err != nil { + t.Errorf("config.GetCredentials() should not throw an error: %v", err) + } + if cred.GetKey() != "cred_c" { + t.Errorf("Key of retreived credential does not match. Want: %v, Got: %v", CredKeyWant, cred.GetKey()) + } + + _, err = config.GetCredentials("cred_d") + if err == nil { + t.Errorf("config.GetCredentials should return error. Got: %v", err) + } + + credWants := []Credentials{ + { + Key: "cred_a", + Username: "user", + Password: "pass", + }, + { + Key: "", + Username: "cred_b", + Password: "pass", + }, + } + + for i := range credWants { + if cmp.Equal(config.Credentials[i], credWants[i]) == false { + t.Errorf("Credentials config %d does not match. Want: %v, Got: %v", i, credWants[i], config.Credentials[i]) + } + } + +} diff --git a/credentials.go b/credentials.go new file mode 100644 index 0000000..8023570 --- /dev/null +++ b/credentials.go @@ -0,0 +1,207 @@ +// Copyright 2020 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" + "github.com/google/go-querystring/query" + "net/url" + "os" + "regexp" + "strings" +) + +type CredentialsErrorInterface interface { + error + SetIndex(index int) + GetIndex() int + GetField() string + Unwrap() error +} + +type CredentialsError struct { + field string + message string + index int + error error +} + +func (e *CredentialsError) Unwrap() error { + return e.error +} + +func (e *CredentialsError) Error() string { + message := e.message + if e.error != nil { + message += ": " + e.error.Error() + } + + message = fmt.Sprintf("validation failed for field %s: %s", e.field, message) + if e.index > 0 { + return fmt.Sprintf("%s (%s credential)", message, index2human(e.index)) + } else { + return message + } + +} +func (e *CredentialsError) SetIndex(index int) { + e.index = index +} +func (e *CredentialsError) GetIndex() int { + return e.index +} +func (e *CredentialsError) GetField() string { + return e.field +} + +type Credentials struct { + Key string `yaml:"key"` + Username string `yaml:"username"` + Password string `yaml:"password"` + SSL SSLCredentials `yaml:"ssl"` +} + +type SSLCredentials struct { + Mode string `yaml:"mode" url:"sslmode,omitempty"` + Cert string `yaml:"cert" url:"sslcert,omitempty"` + Key string `yaml:"key" url:"sslkey,omitempty"` + Password string `yaml:"password" url:"sslpassword,omitempty"` + Compression string `yaml:"compression" url:"sslcompression,omitempty"` + Negotiation string `yaml:"negotiation" url:"sslnegotiation,omitempty"` + CertMode string `yaml:"cert_mode" url:"sslcertmode,omitempty"` + RootCert string `yaml:"root_cert" url:"sslrootcert,omitempty"` +} + +func (c *SSLCredentials) validateFile(key string, file string) CredentialsErrorInterface { + if _, err := os.Stat(file); err != nil { + return &CredentialsError{ + field: fmt.Sprintf("ssl.%s", key), + message: "file error", + error: err, + } + } + return nil +} + +func (c *SSLCredentials) validateEnum(key string, value string, allowedValues []string) CredentialsErrorInterface { + k := false + for _, allowedValue := range allowedValues { + if value == allowedValue { + k = true + continue + } + } + if !k { + return &CredentialsError{ + field: fmt.Sprintf("ssl.%s", key), + message: fmt.Sprintf( + "unsupported value for %s: '%s', must be one of '%s'", + key, + value, + strings.Join(allowedValues, "', '"), + ), + } + } + return nil +} + +func (c *SSLCredentials) Validate() CredentialsErrorInterface { + + if c.Mode != "" { + allowedModes := []string{"disable", "allow", "prefer", "require", "verify-ca", "verify-full"} + err := c.validateEnum("mode", c.Mode, allowedModes) + if err != nil { + return err + } + } + if c.CertMode != "" { + allowedModes := []string{"disable", "allow", "require"} + err := c.validateEnum("certmode", c.CertMode, allowedModes) + if err != nil { + return err + } + } + if c.Negotiation != "" { + allowedModes := []string{"postgres", "direct"} + err := c.validateEnum("negotiation", c.Negotiation, allowedModes) + if err != nil { + return err + } + } + + if c.Cert != "" { + if err := c.validateFile("cert", c.Cert); err != nil { + return err + } + } + + if c.Key != "" { + if err := c.validateFile("key", c.Key); err != nil { + return err + } + } + + if c.RootCert != "" && c.RootCert != "system" { + if err := c.validateFile("root_cert", c.RootCert); err != nil { + return err + } + } + + return nil +} + +func (c *Credentials) UpdateDSN(dsn *url.URL) { + + if c.Password == "" { + dsn.User = url.User(c.Username) + } else { + dsn.User = url.UserPassword(c.Username, c.Password) + } + + sslValues, _ := query.Values(c.SSL) + q := dsn.Query() + for k, v := range sslValues { + for _, vv := range v { + q.Set(k, vv) + } + } + dsn.RawQuery = q.Encode() + +} + +func (c *Credentials) Validate() CredentialsErrorInterface { + + matched, err := regexp.MatchString("^[a-zA-Z0-9_-]+$", c.GetKey()) + if err != nil { + return &CredentialsError{field: "key", message: "key is invalid", error: err} + } else if !matched { + return &CredentialsError{field: "key", message: fmt.Sprintf("key '%s' has invalid characters, should match /^[a-zA-Z0-9_-]+$/", c.GetKey())} + } + + if strings.TrimSpace(c.Username) == "" { + return &CredentialsError{field: "username", message: "username is required"} + } + + return c.SSL.Validate() + +} + +func (c *Credentials) GetKey() string { + + if c.Key == "" { + return c.Username + } + + return c.Key +} diff --git a/credentials_test.go b/credentials_test.go new file mode 100644 index 0000000..8d1e67e --- /dev/null +++ b/credentials_test.go @@ -0,0 +1,219 @@ +// Copyright 2024 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 langu +package main + +import ( + "errors" + "net/url" + "testing" +) + +func TestHappyCredentials(t *testing.T) { + + keyWant := "credential" + usernameWant := "username" + passwordWant := "password" + //clientCertificateWant := "testdata/client.crt" + + cred := Credentials{ + Key: keyWant, + Username: usernameWant, + Password: passwordWant, + } + + err := cred.Validate() + if err != nil { + t.Errorf("Credential validation failed unexpected. Want: nothing, Got: %v", err) + } + + if cred.GetKey() != keyWant { + t.Errorf("Key does not match. Want: %v, Got: %v", keyWant, cred.GetKey()) + } + if cred.Username != usernameWant { + t.Errorf("Username does not match. Want: %v, Got: %v", usernameWant, cred.Username) + } + if cred.Password != passwordWant { + t.Errorf("Password does not match. Want: %v, Got: %v", passwordWant, cred.Password) + } + /* + if cred.ClientCertificate != clientCertificateWant { + t.Errorf("ClientCertificate does not match. Want: %v, Got: %v", clientCertificateWant, cred.ClientCertificate) + } + + */ + + cred = Credentials{ + Username: usernameWant, + Password: passwordWant, + } + + err = cred.Validate() + if err != nil { + t.Errorf("Credential validation failed unexpected. Want: nothing, Got: %v", err) + } + + if cred.GetKey() != usernameWant { + t.Errorf("Key does not match. Want: %v, Got: %v", usernameWant, cred.GetKey()) + } + if cred.Username != usernameWant { + t.Errorf("Username does not match. Want: %v, Got: %v", usernameWant, cred.Username) + } + if cred.Password != passwordWant { + t.Errorf("Password does not match. Want: %v, Got: %v", passwordWant, cred.Password) + } + +} + +func TestUnHappyCredentials(t *testing.T) { + + var errCred *CredentialsError + var err CredentialsErrorInterface + + unhappyTests := []struct { + wantField string + cred Credentials + }{ + { + wantField: "key", + cred: Credentials{ + Key: "key with spaces", + }, + }, + { + wantField: "key", + cred: Credentials{ + Username: "username with spaces", + }, + }, + { + wantField: "ssl.mode", + cred: Credentials{ + Key: "test", + Username: "username", + SSL: SSLCredentials{Mode: "test"}, + }, + }, + { + wantField: "ssl.cert", + cred: Credentials{ + Key: "test", + Username: "username", + SSL: SSLCredentials{Cert: "test"}, + }, + }, + } + + for _, test := range unhappyTests { + + err = test.cred.Validate() + if err == nil { + t.Errorf("credential.Validate should fail with an error, no error was returned") + } else if errors.As(err, &errCred) == false { + t.Errorf("credential.Validate should return CredentialsError error. Got: %v", err) + } else if err.GetField() != test.wantField { + t.Errorf("credential.Validate failed on unexpected field. Want: %s, Got: %v", test.wantField, err.GetField()) + } else if err.Error() == "" { + t.Errorf("credential.Validate error has empty string as Error(). Got: %v", err) + } + + } + +} + +func TestUpdateDSN(t *testing.T) { + + cred := Credentials{ + Key: "test", + Username: "username", + Password: "password", + SSL: SSLCredentials{}, + } + + sslCred := Credentials{ + Key: "testssl", + Username: "username", + SSL: SSLCredentials{ + Mode: "verify-full", + Cert: "testdata/client.crt", + Key: "testdata/client.crt", + Password: "password", + Compression: "1", + Negotiation: "postgres", + CertMode: "allow", + RootCert: "testdata/client.crt", // just pointing to an existing file, file content is not used + }, + } + + var dsn *url.URL + var err error + startDSN := "postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable" + dsn, err = url.Parse(startDSN) + if err != nil { + t.Errorf("Failed to parse DSN, this is a error in the test suite: %v", err) + } else if dsn.String() != startDSN { + t.Errorf("DSN does not match. Want: %v, Got: %v", startDSN, dsn) + } + + wantSimple := "postgres://username:password@localhost:6543/pgbouncer?sslmode=disable" + cred.UpdateDSN(dsn) + if dsn.String() != wantSimple { + t.Errorf("Updated DSN does not match. Want: %v, Got: %v", wantSimple, dsn.String()) + } + + dsn, _ = url.Parse(startDSN) + wantSSL := "postgres://username@localhost:6543/pgbouncer?sslcert=testdata%2Fclient.crt&sslcertmode=allow&sslcompression=1&sslkey=testdata%2Fclient.crt&sslmode=verify-full&sslnegotiation=postgres&sslpassword=password&sslrootcert=testdata%2Fclient.crt" + + sslCred.UpdateDSN(dsn) + if dsn.String() != wantSSL { + t.Errorf("Updated DSN does not match. \nWant: %v, \nGot: %v", wantSSL, dsn.String()) + } + +} + +func TestIndexedCredentialError(t *testing.T) { + err := CredentialsError{ + field: "test", + message: "test message", + index: 2, + } + + if err.GetIndex() != 2 { + t.Errorf("GetIndex does not match. Want: %v, Got: %v", 2, err.GetIndex()) + } + + want := "validation failed for field test: test message (2nd credential)" + if err.Error() != want { + t.Errorf("Error does not match. \nWant: %v, \nGot: %v", want, err.Error()) + } + +} +func TestWrappedCredentialError(t *testing.T) { + err := CredentialsError{ + field: "test", + message: "test message", + error: errors.New("sub error"), + } + + wrappedErr := err.Unwrap() + want := "sub error" + if wrappedErr == nil { + t.Errorf("Unwrap should return error. Got: %v", err.error) + } else if wrappedErr.Error() != "sub error" { + t.Errorf("Unwrapped error does not match. Want: %v, Got: %v", want, err.Error()) + } + + want = "validation failed for field test: test message: sub error" + if err.Error() != want { + t.Errorf("Error does not match. \nWant: %v, \nGot: %v", want, err.Error()) + } + +} diff --git a/go.mod b/go.mod index a8d8e0e..a0fddea 100644 --- a/go.mod +++ b/go.mod @@ -5,12 +5,15 @@ go 1.22 require ( github.com/DATA-DOG/go-sqlmock v1.5.2 github.com/alecthomas/kingpin/v2 v2.4.0 + github.com/google/go-cmp v0.6.0 + github.com/google/go-querystring v1.1.0 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.20.4 github.com/prometheus/client_model v0.6.1 github.com/prometheus/common v0.61.0 github.com/prometheus/exporter-toolkit v0.13.2 github.com/smartystreets/goconvey v1.8.1 + gopkg.in/yaml.v3 v3.0.1 ) require ( diff --git a/go.sum b/go.sum index 8cb3d32..417952c 100644 --- a/go.sum +++ b/go.sum @@ -14,8 +14,11 @@ github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSs github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/gopherjs/gopherjs v1.17.2 h1:fQnZVsXk8uxXIStYb0N4bGk7jeyTalG/wsZjQ25dO0g= github.com/gopherjs/gopherjs v1.17.2/go.mod h1:pRRIvn/QzFLrKfvEz3qUuEhtE/zLCWfreZ6J5gM2i+k= github.com/jpillora/backoff v1.0.0 h1:uvFg412JmmHBHw7iwprIxkPMI+sGQ4kzOWsMeHnm2EA= @@ -77,6 +80,7 @@ golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/pgbouncer_exporter.go b/pgbouncer_exporter.go index a7e112b..129d08b 100644 --- a/pgbouncer_exporter.go +++ b/pgbouncer_exporter.go @@ -14,9 +14,6 @@ package main import ( - "net/http" - "os" - "github.com/alecthomas/kingpin/v2" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/collectors" @@ -27,10 +24,21 @@ import ( "github.com/prometheus/common/version" "github.com/prometheus/exporter-toolkit/web" "github.com/prometheus/exporter-toolkit/web/kingpinflag" + "net/http" + "net/url" + "os" ) const namespace = "pgbouncer" +const ( + _ = iota + ExitCodeWebServerError + ExitConfigFileReadError + ExitConfigFileContentError + ExitConfigError +) + func main() { const pidFileHelpText = `Path to PgBouncer pid file. @@ -41,13 +49,25 @@ func main() { https://prometheus.io/docs/instrumenting/writing_clientlibs/#process-metrics.` + const cfgFileHelpText = `Path to config file for multiple pgbouncer instances . + + If provided, the standard pgbouncer parameters, 'pgBouncer.connectionString' + and 'pgBouncer.pid-file', will be ignored and read from the config file` + + config := NewDefaultConfig() + promslogConfig := &promslog.Config{} flag.AddFlags(kingpin.CommandLine, promslogConfig) var ( + metricsPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Default(config.MetricsPath).String() + probePath = kingpin.Flag("web.probe-path", "Path under which to expose probe metrics.").Default(config.ProbePath).String() + connectionStringPointer = kingpin.Flag("pgBouncer.connectionString", "Connection string for accessing pgBouncer.").Default("postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable").Envar("PGBOUNCER_EXPORTER_CONNECTION_STRING").String() - metricsPath = kingpin.Flag("web.telemetry-path", "Path under which to expose metrics.").Default("/metrics").String() pidFilePath = kingpin.Flag("pgBouncer.pid-file", pidFileHelpText).Default("").String() + + configFilePath = kingpin.Flag("config.file", cfgFileHelpText).Default("").String() + err error ) toolkitFlags := kingpinflag.AddFlags(kingpin.CommandLine, ":9127") @@ -58,35 +78,111 @@ func main() { logger := promslog.New(promslogConfig) - connectionString := *connectionStringPointer - exporter := NewExporter(connectionString, namespace, logger) - prometheus.MustRegister(exporter) + // Update config with values from the command-line parameters + if metricsPath != nil && *metricsPath != "" { + config.MetricsPath = *metricsPath + } + if probePath != nil && *probePath != "" { + config.ProbePath = *probePath + } + if connectionStringPointer != nil && *connectionStringPointer != "" { + config.DSN = *connectionStringPointer + } + if pidFilePath != nil && *pidFilePath != "" { + config.PidFile = *pidFilePath + } + + // Read and apply config from config file. + // When using a config file legacy_mode is disabled by default. + if configFilePath != nil && *configFilePath != "" { + err = config.ReadFromFile(*configFilePath) + if err != nil { + logger.Error("Error reading config file", "file", *configFilePath, "err", err) + os.Exit(ExitConfigFileReadError) + } + } + + if config.MetricsPath == config.ProbePath { + logger.Error("Metrics and probe paths cannot be the same path", "metrics", config.MetricsPath, "probe", config.ProbePath) + os.Exit(ExitConfigError) + } + + if config.LegacyMode { + + logger.Info("Running in legacy mode") + + // In legacy mode start the exporter in single-target mode with one scrape endpoint en no probe option + exporter := NewExporter(config.DSN, namespace, logger, config.MustConnectOnStartup) + prometheus.MustRegister(exporter) + + if config.PidFile != "" { + procExporter := collectors.NewProcessCollector( + collectors.ProcessCollectorOpts{ + PidFn: prometheus.NewPidFileFn(config.PidFile), + Namespace: namespace, + }, + ) + prometheus.MustRegister(procExporter) + } + } else { + logger.Info("Running in multi-target mode") + http.HandleFunc(config.ProbePath, func(w http.ResponseWriter, r *http.Request) { + params := r.URL.Query() + + // Get DSN + dsn := params.Get("dsn") + dsnURL, err := url.Parse(dsn) + if err != nil { + logger.Warn("Error parsing dsn", "dsn", dsn, "err", err) + w.WriteHeader(http.StatusBadRequest) + return + } + + // Apply Credential + cred := params.Get("cred") + if cred != "" { + creds, err := config.GetCredentials(cred) + if err != nil { + logger.Error("Error getting credentials", "cred", cred, "err", err) + w.WriteHeader(http.StatusBadRequest) + return + } + creds.UpdateDSN(dsnURL) + } + + registry := prometheus.NewRegistry() + err = registry.Register(NewExporter(dsnURL.String(), namespace, logger, false)) + if err != nil { + logger.Error("Error registering exporter", "err", err) + w.WriteHeader(http.StatusBadRequest) + return + } + h := promhttp.HandlerFor(registry, promhttp.HandlerOpts{}) + h.ServeHTTP(w, r) + }) + } + prometheus.MustRegister(versioncollector.NewCollector("pgbouncer_exporter")) logger.Info("Starting pgbouncer_exporter", "version", version.Info()) logger.Info("Build context", "build_context", version.BuildContext()) - if *pidFilePath != "" { - procExporter := collectors.NewProcessCollector( - collectors.ProcessCollectorOpts{ - PidFn: prometheus.NewPidFileFn(*pidFilePath), - Namespace: namespace, - }, - ) - prometheus.MustRegister(procExporter) - } + http.Handle(config.MetricsPath, promhttp.Handler()) - http.Handle(*metricsPath, promhttp.Handler()) - if *metricsPath != "/" && *metricsPath != "" { + if config.MetricsPath != "/" && config.MetricsPath != "" && config.ProbePath != "/" && config.ProbePath != "" { landingConfig := web.LandingConfig{ Name: "PgBouncer Exporter", Description: "Prometheus Exporter for PgBouncer servers", Version: version.Info(), Links: []web.LandingLinks{ { - Address: *metricsPath, + Address: config.MetricsPath, Text: "Metrics", }, + { + Address: config.ProbePath, + Text: "Probe", + }, }, } landingPage, err := web.NewLandingPage(landingConfig) @@ -100,6 +196,6 @@ func main() { srv := &http.Server{} if err := web.ListenAndServe(srv, toolkitFlags, logger); err != nil { logger.Error("Error starting server", "err", err) - os.Exit(1) + os.Exit(ExitCodeWebServerError) } } diff --git a/testdata/client.crt b/testdata/client.crt new file mode 100644 index 0000000..9914bd4 --- /dev/null +++ b/testdata/client.crt @@ -0,0 +1,3 @@ +-----BEGIN CERTIFICATE----- +ZW1wdHkK +-----END CERTIFICATE----- \ No newline at end of file diff --git a/testdata/client.key b/testdata/client.key new file mode 100644 index 0000000..0060d08 --- /dev/null +++ b/testdata/client.key @@ -0,0 +1 @@ +PRIVATE KEY \ No newline at end of file diff --git a/testdata/config-legacy.yaml b/testdata/config-legacy.yaml new file mode 100644 index 0000000..e3548f5 --- /dev/null +++ b/testdata/config-legacy.yaml @@ -0,0 +1,8 @@ +metrics_path: /metrics +probe_path: /probe + +legacy_mode: true +dsn: postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable +pid_file: +must_connect_on_startup: true + diff --git a/testdata/config.yaml b/testdata/config.yaml new file mode 100644 index 0000000..6dcfc55 --- /dev/null +++ b/testdata/config.yaml @@ -0,0 +1,14 @@ +metrics_path: /prom +probe_path: /data + +credentials: + - key: cred_a + username: user + password: pass + - username: cred_b + password: pass + - username: cred_c + ssl: + mode: verify-full + cert: testdata/client.crt + key: testdata/client.key diff --git a/testdata/dsn-legacy-off.yaml b/testdata/dsn-legacy-off.yaml new file mode 100644 index 0000000..090910c --- /dev/null +++ b/testdata/dsn-legacy-off.yaml @@ -0,0 +1,4 @@ +legacy_mode: false +dsn: postgres://postgres:@localhost:6543/pgbouncer?sslmode=disable +must_connect_on_startup: true + diff --git a/testdata/duplicate_creds.yaml b/testdata/duplicate_creds.yaml new file mode 100644 index 0000000..db378f4 --- /dev/null +++ b/testdata/duplicate_creds.yaml @@ -0,0 +1,9 @@ +metrics_path: /metrics +probe_path: /probe + +credentials: + - key: stats + username: user + password: pass + - username: stats + password: pass \ No newline at end of file diff --git a/testdata/invalid_creds.yaml b/testdata/invalid_creds.yaml new file mode 100644 index 0000000..217f898 --- /dev/null +++ b/testdata/invalid_creds.yaml @@ -0,0 +1,13 @@ +metrics_path: /metrics +probe_path: /probe + +credentials: + - key: stats + username: user + password: pass + ssl: + mode: verify-full + cert: testdata/client.crt + key: testdata/missing_client.key + + diff --git a/testdata/parse_error.yaml b/testdata/parse_error.yaml new file mode 100644 index 0000000..d84ce33 --- /dev/null +++ b/testdata/parse_error.yaml @@ -0,0 +1,8 @@ +# yamllint disable-file +# This file is invalid on purpose and should throw errors +metrics_path: /metrics +probe_path: /probe + +credentials: + key: value + - trigger: parse_error #syntax error: mapping values are not allowed here (syntax)