From 0d7009382d08de2f3cddc17c4512819b9a330043 Mon Sep 17 00:00:00 2001 From: Thomas Boerger Date: Tue, 26 Mar 2019 17:21:38 +0100 Subject: [PATCH] Add support for multiple credentials --- .gitignore | 1 + CHANGELOG.md | 4 ++ Gopkg.lock | 7 ++- Gopkg.toml | 4 ++ cmd/prometheus-hetzner-sd/main.go | 73 +++++++++++++++++----- cmd/prometheus-hetzner-sd/setup.go | 37 +++++++++++ config/example.json | 31 ++++++++++ config/example.yaml | 21 +++++++ docs/content/getting-started.md | 11 ++++ pkg/action/discoverer.go | 99 ++++++++++++++++-------------- pkg/action/metrics.go | 6 +- pkg/action/server.go | 13 ++-- pkg/config/config.go | 28 +++++---- 13 files changed, 252 insertions(+), 83 deletions(-) create mode 100644 config/example.json create mode 100644 config/example.yaml diff --git a/.gitignore b/.gitignore index 70772be..2a7da7a 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,4 @@ coverage.out .envrc hetzner.json +config.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 3e95dd6..3d186c6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,10 @@ * Switch to cloud.drone.io for CI +### Added + +* Support multiple credentials and config file + ## [0.2.0] - 2019-01-11 ### Added diff --git a/Gopkg.lock b/Gopkg.lock index 6624b38..493806b 100644 --- a/Gopkg.lock +++ b/Gopkg.lock @@ -643,12 +643,12 @@ revision = "d3ae77c26ac8db90639677e4831a728d33c36111" [[projects]] - digest = "1:4d2e5a73dc1500038e504a8d78b986630e3626dc027bc030ba5c75da257cdb96" + branch = "v2" + digest = "1:2d56cd111a870bdf3ee37601877effe1fc79b8983645c147846f4769b6eb7051" name = "gopkg.in/yaml.v2" packages = ["."] pruneopts = "UT" - revision = "51d6538a90f86fe93ac480b35f37b2be17fef232" - version = "v2.2.2" + revision = "7b8349ac747c6a24702b762d2c4fd9266cf4f1d6" [[projects]] branch = "master" @@ -813,6 +813,7 @@ "github.com/prometheus/prometheus/discovery", "github.com/prometheus/prometheus/discovery/targetgroup", "gopkg.in/urfave/cli.v2", + "gopkg.in/yaml.v2", ] solver-name = "gps-cdcl" solver-version = 1 diff --git a/Gopkg.toml b/Gopkg.toml index 4bc8a0a..4ce5a56 100644 --- a/Gopkg.toml +++ b/Gopkg.toml @@ -53,3 +53,7 @@ [prune] go-tests = true unused-packages = true + +[[constraint]] + branch = "v2" + name = "gopkg.in/yaml.v2" diff --git a/cmd/prometheus-hetzner-sd/main.go b/cmd/prometheus-hetzner-sd/main.go index 367c1a7..b88beed 100644 --- a/cmd/prometheus-hetzner-sd/main.go +++ b/cmd/prometheus-hetzner-sd/main.go @@ -21,6 +21,9 @@ var ( // ErrMissingHetznerPassword defines the error if hetzner.password is empty. ErrMissingHetznerPassword = errors.New("Missing required hetzner.password") + + // ErrMissingAnyCredentials defines the error if no credentials are provided. + ErrMissingAnyCredentials = errors.New("Missing any credentials") ) func main() { @@ -90,23 +93,38 @@ func main() { Destination: &cfg.Target.Refresh, }, &cli.StringFlag{ - Name: "hetzner.username", - Value: "", - Usage: "Username for the Hetzner API", - EnvVars: []string{"PROMETHEUS_HETZNER_USERNAME"}, - Destination: &cfg.Target.Username, + Name: "hetzner.username", + Value: "", + Usage: "Username for the Hetzner API", + EnvVars: []string{"PROMETHEUS_HETZNER_USERNAME"}, + }, + &cli.StringFlag{ + Name: "hetzner.password", + Value: "", + Usage: "Password for the Hetzner API", + EnvVars: []string{"PROMETHEUS_HETZNER_PASSWORD"}, }, &cli.StringFlag{ - Name: "hetzner.password", - Value: "", - Usage: "Password for the Hetzner API", - EnvVars: []string{"PROMETHEUS_HETZNER_PASSWORD"}, - Destination: &cfg.Target.Password, + Name: "hetzner.config", + Value: "", + Usage: "Path to Hetzner configuration file", + EnvVars: []string{"PROMETHEUS_HETZNER_CONFIG"}, }, }, Action: func(c *cli.Context) error { logger := setupLogger(cfg) + if c.IsSet("hetzner.config") { + if err := readConfig(c.String("hetzner.config"), cfg); err != nil { + level.Error(logger).Log( + "msg", "Failed to read config", + "err", err, + ) + + return err + } + } + if cfg.Target.File == "" { level.Error(logger).Log( "msg", ErrMissingOutputFile, @@ -115,20 +133,41 @@ func main() { return ErrMissingOutputFile } - if cfg.Target.Username == "" { - level.Error(logger).Log( - "msg", ErrMissingHetznerUsername, + if c.IsSet("hetzner.username") && c.IsSet("hetzner.password") { + credentials := config.Credential{ + Project: "default", + Username: c.String("hetzner.username"), + Password: c.String("hetzner.password"), + } + + cfg.Target.Credentials = append( + cfg.Target.Credentials, + credentials, ) - return ErrMissingHetznerUsername + if credentials.Username == "" { + level.Error(logger).Log( + "msg", ErrMissingHetznerUsername, + ) + + return ErrMissingHetznerUsername + } + + if credentials.Password == "" { + level.Error(logger).Log( + "msg", ErrMissingHetznerPassword, + ) + + return ErrMissingHetznerPassword + } } - if cfg.Target.Password == "" { + if len(cfg.Target.Credentials) == 0 { level.Error(logger).Log( - "msg", ErrMissingHetznerPassword, + "msg", ErrMissingAnyCredentials, ) - return ErrMissingHetznerPassword + return ErrMissingAnyCredentials } return action.Server(cfg, logger) diff --git a/cmd/prometheus-hetzner-sd/setup.go b/cmd/prometheus-hetzner-sd/setup.go index 1c6f99d..4b59912 100644 --- a/cmd/prometheus-hetzner-sd/setup.go +++ b/cmd/prometheus-hetzner-sd/setup.go @@ -1,7 +1,12 @@ package main import ( + "encoding/json" + "errors" + "gopkg.in/yaml.v2" + "io/ioutil" "os" + "path/filepath" "strings" "github.com/go-kit/kit/log" @@ -9,6 +14,11 @@ import ( "github.com/promhippie/prometheus-hetzner-sd/pkg/config" ) +var ( + // ErrConfigFormatInvalid defines the error if ext is unsupported. + ErrConfigFormatInvalid = errors.New("Config extension is not supported") +) + func setupLogger(cfg *config.Config) log.Logger { var logger log.Logger @@ -40,3 +50,30 @@ func setupLogger(cfg *config.Config) log.Logger { "ts", log.DefaultTimestampUTC, ) } + +func readConfig(file string, cfg *config.Config) error { + if file == "" { + return nil + } + + content, err := ioutil.ReadFile(file) + + if err != nil { + return err + } + + switch strings.ToLower(filepath.Ext(file)) { + case ".yaml", ".yml": + if err = yaml.Unmarshal(content, cfg); err != nil { + return err + } + case ".json": + if err = json.Unmarshal(content, cfg); err != nil { + return err + } + default: + return ErrConfigFormatInvalid + } + + return nil +} diff --git a/config/example.json b/config/example.json new file mode 100644 index 0000000..5054308 --- /dev/null +++ b/config/example.json @@ -0,0 +1,31 @@ +{ + "server": { + "addr": "0.0.0.0:9000", + "path": "/metrics" + }, + "logs": { + "level": "error", + "pretty": false + }, + "target": { + "file": "/etc/prometheus/hetzner.json", + "refresh": 30, + "credentials": [ + { + "project": "example1", + "username": "#ws+E9WaCWqg", + "password": "nmkEoHQWgnzThGmbfQ6Dojwf" + }, + { + "project": "example2", + "username": "#ws+bmnA3gtt", + "password": "xapPbhgoRwEaRAHpKMnxa7YR" + }, + { + "project": "example3", + "username": "#ws+Mk6uueNd", + "password": "YmmvhAXAeejpxWJxTzf9kjXm" + } + ] + } +} diff --git a/config/example.yaml b/config/example.yaml new file mode 100644 index 0000000..e6ce8c9 --- /dev/null +++ b/config/example.yaml @@ -0,0 +1,21 @@ +server: + addr: 0.0.0.0:9000 + path: /metrics + +logs: + level: error + pretty: false + +target: + file: /etc/prometheus/hetzner.json + refresh: 30 + credentials: + - project: example1 + username: '#ws+E9WaCWqg' + password: nmkEoHQWgnzThGmbfQ6Dojwf + - project: example2 + username: '#ws+bmnA3gtt' + password: xapPbhgoRwEaRAHpKMnxa7YR + - project: example3 + username: '#ws+Mk6uueNd' + password: YmmvhAXAeejpxWJxTzf9kjXm diff --git a/docs/content/getting-started.md b/docs/content/getting-started.md index 4091347..7df1722 100644 --- a/docs/content/getting-started.md +++ b/docs/content/getting-started.md @@ -39,6 +39,13 @@ Currently we have not prepared a deployment for Kubernetes, but this is somethin ## Configuration +### Envrionment variables + +If you prefer to configure the service with environment variables you can see the available variables below, in case you want to configure multiple accounts with a single service you are forced to use the configuration file as the environment variables are limited to a single account. As the service is pretty lightweight you can even start an instance per account and configure it entirely by the variables, it's up to you. + +PROMETHEUS_HETZNER_CONFIG +: Path to Hetzner configuration file, optionally, required for muli credentials + PROMETHEUS_HETZNER_USERNAME : Username for the Hetzner API, required for authentication @@ -63,6 +70,10 @@ PROMETHEUS_HETZNER_OUTPUT_FILE PROMETHEUS_HETZNER_OUTPUT_REFRESH : Discovery refresh interval in seconds, defaults to `30` +### Configuration file + +Especially if you want to configure multiple accounts within a single service discovery you got to use the configuration file. So far we support the file formats `JSON` and `YAML`, if you want to get a full example configuration just take a look at [our repository](https://github.com/promhippie/prometheus-hetzner-sd/tree/master/config), there you can always see the latest configuration format. These example configurations include all available options, they also include the default values. + ## Labels * `__meta_hetzner_name` diff --git a/pkg/action/discoverer.go b/pkg/action/discoverer.go index 90554cb..9f6956a 100644 --- a/pkg/action/discoverer.go +++ b/pkg/action/discoverer.go @@ -16,6 +16,7 @@ import ( const ( hetznerPrefix = model.MetaLabelPrefix + "hetzner_" + projectLabel = hetznerPrefix + "project" nameLabel = hetznerPrefix + "name" numberLabel = hetznerPrefix + "number" ipLabel = hetznerPrefix + "ipv4" @@ -30,7 +31,7 @@ const ( // Discoverer implements the Prometheus discoverer interface. type Discoverer struct { - client *hetzner.Client + clients map[string]*hetzner.Client logger log.Logger refresh int lasts map[string]struct{} @@ -57,58 +58,66 @@ func (d Discoverer) Run(ctx context.Context, ch chan<- []*targetgroup.Group) { } func (d *Discoverer) getTargets(ctx context.Context) ([]*targetgroup.Group, error) { - now := time.Now() - servers, _, err := d.client.Server.ListServers() - requestDuration.Observe(time.Since(now).Seconds()) - - if err != nil { - level.Warn(d.logger).Log( - "msg", "Failed to fetch servers", - "err", err, - ) + current := make(map[string]struct{}) + targets := make([]*targetgroup.Group, 0) - requestFailures.Inc() - return nil, err - } + for project, client := range d.clients { - level.Debug(d.logger).Log( - "msg", "Requested servers", - "count", len(servers), - ) + now := time.Now() + servers, _, err := client.Server.ListServers() + requestDuration.WithLabelValues(project).Observe(time.Since(now).Seconds()) - current := make(map[string]struct{}) - targets := make([]*targetgroup.Group, len(servers)) - - for _, server := range servers { - target := &targetgroup.Group{ - Source: fmt.Sprintf("hetzner/%d", server.ServerNumber), - Targets: []model.LabelSet{ - { - model.AddressLabel: model.LabelValue(server.ServerIP), - }, - }, - Labels: model.LabelSet{ - model.AddressLabel: model.LabelValue(server.ServerIP), - model.LabelName(nameLabel): model.LabelValue(server.ServerName), - model.LabelName(numberLabel): model.LabelValue(strconv.Itoa(int(server.ServerNumber))), - model.LabelName(ipLabel): model.LabelValue(server.ServerIP), - model.LabelName(productLabel): model.LabelValue(server.Product), - model.LabelName(dcLabel): model.LabelValue(strings.ToLower(server.Dc)), - model.LabelName(trafficLabel): model.LabelValue(server.Traffic), - model.LabelName(flatrateLabel): model.LabelValue(strconv.FormatBool(server.Flatrate)), - model.LabelName(statusLabel): model.LabelValue(server.Status), - model.LabelName(throttledLabel): model.LabelValue(strconv.FormatBool(server.Throttled)), - model.LabelName(cancelledLabel): model.LabelValue(strconv.FormatBool(server.Cancelled)), - }, + if err != nil { + level.Warn(d.logger).Log( + "msg", "Failed to fetch servers", + "project", project, + "err", err, + ) + + requestFailures.WithLabelValues(project).Inc() + return nil, err } level.Debug(d.logger).Log( - "msg", "Server added", - "source", target.Source, + "msg", "Requested servers", + "project", project, + "count", len(servers), ) - current[target.Source] = struct{}{} - targets = append(targets, target) + for _, server := range servers { + target := &targetgroup.Group{ + Source: fmt.Sprintf("hetzner/%d", server.ServerNumber), + Targets: []model.LabelSet{ + { + model.AddressLabel: model.LabelValue(server.ServerIP), + }, + }, + Labels: model.LabelSet{ + model.AddressLabel: model.LabelValue(server.ServerIP), + model.LabelName(projectLabel): model.LabelValue(project), + model.LabelName(nameLabel): model.LabelValue(server.ServerName), + model.LabelName(numberLabel): model.LabelValue(strconv.Itoa(int(server.ServerNumber))), + model.LabelName(ipLabel): model.LabelValue(server.ServerIP), + model.LabelName(productLabel): model.LabelValue(server.Product), + model.LabelName(dcLabel): model.LabelValue(strings.ToLower(server.Dc)), + model.LabelName(trafficLabel): model.LabelValue(server.Traffic), + model.LabelName(flatrateLabel): model.LabelValue(strconv.FormatBool(server.Flatrate)), + model.LabelName(statusLabel): model.LabelValue(server.Status), + model.LabelName(throttledLabel): model.LabelValue(strconv.FormatBool(server.Throttled)), + model.LabelName(cancelledLabel): model.LabelValue(strconv.FormatBool(server.Cancelled)), + }, + } + + level.Debug(d.logger).Log( + "msg", "Server added", + "project", project, + "source", target.Source, + ) + + current[target.Source] = struct{}{} + targets = append(targets, target) + } + } for k := range d.lasts { diff --git a/pkg/action/metrics.go b/pkg/action/metrics.go index f721ea9..5598280 100644 --- a/pkg/action/metrics.go +++ b/pkg/action/metrics.go @@ -15,21 +15,23 @@ var ( ) var ( - requestDuration = prometheus.NewHistogram( + requestDuration = prometheus.NewHistogramVec( prometheus.HistogramOpts{ Namespace: namespace, Name: "request_duration_seconds", Help: "Histogram of latencies for requests to the Hetzner API.", Buckets: []float64{0.001, 0.01, 0.1, 0.5, 1.0, 2.0, 5.0, 10.0}, }, + []string{"project"}, ) - requestFailures = prometheus.NewCounter( + requestFailures = prometheus.NewCounterVec( prometheus.CounterOpts{ Namespace: namespace, Name: "request_failures_total", Help: "Total number of failed requests to the Hetzner API.", }, + []string{"project"}, ) ) diff --git a/pkg/action/server.go b/pkg/action/server.go index b4648b3..2a40082 100644 --- a/pkg/action/server.go +++ b/pkg/action/server.go @@ -34,14 +34,17 @@ func Server(cfg *config.Config, logger log.Logger) error { { ctx := context.Background() + clients := make(map[string]*hetzner.Client, len(cfg.Target.Credentials)) - client := hetzner.NewClient( - cfg.Target.Username, - cfg.Target.Password, - ) + for _, credential := range cfg.Target.Credentials { + clients[credential.Project] = hetzner.NewClient( + credential.Username, + credential.Password, + ) + } disc := Discoverer{ - client: client, + clients: clients, logger: logger, refresh: cfg.Target.Refresh, lasts: make(map[string]struct{}), diff --git a/pkg/config/config.go b/pkg/config/config.go index ad97776..7f83dda 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -1,30 +1,36 @@ package config +// Credential defines a single project credential. +type Credential struct { + Project string `json:"project" yaml:"project"` + Username string `json:"username" yaml:"username"` + Password string `json:"password" yaml:"password"` +} + // Server defines the general server configuration. type Server struct { - Addr string - Path string + Addr string `json:"addr" yaml:"addr"` + Path string `json:"path" yaml:"path"` } // Logs defines the level and color for log configuration. type Logs struct { - Level string - Pretty bool + Level string `json:"level" yaml:"level"` + Pretty bool `json:"pretty" yaml:"pretty"` } // Target defines the target specific configuration. type Target struct { - File string - Refresh int - Username string - Password string + File string `json:"file" yaml:"file"` + Refresh int `json:"refresh" yaml:"refresh"` + Credentials []Credential `json:"credentials" yaml:"credentials"` } // Config is a combination of all available configurations. type Config struct { - Server Server - Logs Logs - Target Target + Server Server `json:"server" yaml:"server"` + Logs Logs `json:"logs" yaml:"logs"` + Target Target `json:"target" yaml:"target"` } // Load initializes a default configuration struct.