Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement multitarget pattern support #198

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,52 @@ Exports metrics at `9127/metrics`
make build
./pgbouncer_exporter <flags>

## 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. <sup>Can be set by `legacy_mode`</sup>

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:
Expand Down
26 changes: 20 additions & 6 deletions collector.go
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down Expand Up @@ -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
}

Expand Down
122 changes: 122 additions & 0 deletions config.go
Original file line number Diff line number Diff line change
@@ -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)

}
37 changes: 37 additions & 0 deletions config.yaml
Original file line number Diff line number Diff line change
@@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There should be no "legacy mode". Both normal scrape and probe endpoints should be supported simultaneously.

## 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
136 changes: 136 additions & 0 deletions config_test.go
Original file line number Diff line number Diff line change
@@ -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])
}
}

}
Loading