diff --git a/config/config_test.go b/config/config_test.go index 9cab2f9e0..1e30ab299 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -47,6 +47,7 @@ import ( "github.com/prometheus/prometheus/discovery/moby" "github.com/prometheus/prometheus/discovery/nomad" "github.com/prometheus/prometheus/discovery/openstack" + "github.com/prometheus/prometheus/discovery/ovhcloud" "github.com/prometheus/prometheus/discovery/puppetdb" "github.com/prometheus/prometheus/discovery/scaleway" "github.com/prometheus/prometheus/discovery/targetgroup" @@ -940,6 +941,35 @@ var expectedConf = &Config{ }, }, }, + { + JobName: "ovhcloud", + + HonorTimestamps: true, + ScrapeInterval: model.Duration(15 * time.Second), + ScrapeTimeout: DefaultGlobalConfig.ScrapeTimeout, + HTTPClientConfig: config.DefaultHTTPClientConfig, + MetricsPath: DefaultScrapeConfig.MetricsPath, + Scheme: DefaultScrapeConfig.Scheme, + + ServiceDiscoveryConfigs: discovery.Configs{ + &ovhcloud.SDConfig{ + Endpoint: "ovh-eu", + ApplicationKey: "testAppKey", + ApplicationSecret: "testAppSecret", + ConsumerKey: "testConsumerKey", + RefreshInterval: model.Duration(60 * time.Second), + Service: "vps", + }, + &ovhcloud.SDConfig{ + Endpoint: "ovh-eu", + ApplicationKey: "testAppKey", + ApplicationSecret: "testAppSecret", + ConsumerKey: "testConsumerKey", + RefreshInterval: model.Duration(60 * time.Second), + Service: "dedicated_server", + }, + }, + }, { JobName: "scaleway", @@ -1175,7 +1205,7 @@ func TestElideSecrets(t *testing.T) { yamlConfig := string(config) matches := secretRe.FindAllStringIndex(yamlConfig, -1) - require.Equal(t, 18, len(matches), "wrong number of secret matches found") + require.Equal(t, 22, len(matches), "wrong number of secret matches found") require.NotContains(t, yamlConfig, "mysecret", "yaml marshal reveals authentication credentials.") } @@ -1618,6 +1648,14 @@ var expectedErrors = []struct { filename: "ionos_datacenter.bad.yml", errMsg: "datacenter id can't be empty", }, + { + filename: "ovhcloud_no_secret.bad.yml", + errMsg: "application secret can not be empty", + }, + { + filename: "ovhcloud_bad_service.bad.yml", + errMsg: "unknown service: fakeservice", + }, } func TestBadConfigs(t *testing.T) { diff --git a/config/testdata/conf.good.yml b/config/testdata/conf.good.yml index c19b7c1e6..da0042b94 100644 --- a/config/testdata/conf.good.yml +++ b/config/testdata/conf.good.yml @@ -349,6 +349,21 @@ scrape_configs: eureka_sd_configs: - server: "http://eureka.example.com:8761/eureka" + - job_name: ovhcloud + ovhcloud_sd_configs: + - service: vps + endpoint: ovh-eu + application_key: testAppKey + application_secret: testAppSecret + consumer_key: testConsumerKey + refresh_interval: 1m + - service: dedicated_server + endpoint: ovh-eu + application_key: testAppKey + application_secret: testAppSecret + consumer_key: testConsumerKey + refresh_interval: 1m + - job_name: scaleway scaleway_sd_configs: - role: instance diff --git a/config/testdata/ovhcloud_bad_service.bad.yml b/config/testdata/ovhcloud_bad_service.bad.yml new file mode 100644 index 000000000..3c2fa03cc --- /dev/null +++ b/config/testdata/ovhcloud_bad_service.bad.yml @@ -0,0 +1,8 @@ +scrape_configs: + - ovhcloud_sd_configs: + - service: fakeservice + endpoint: ovh-eu + application_key: testAppKey + application_secret: testAppSecret + consumer_key: testConsumerKey + refresh_interval: 1m diff --git a/config/testdata/ovhcloud_no_secret.bad.yml b/config/testdata/ovhcloud_no_secret.bad.yml new file mode 100644 index 000000000..1959bee87 --- /dev/null +++ b/config/testdata/ovhcloud_no_secret.bad.yml @@ -0,0 +1,7 @@ +scrape_configs: + - ovhcloud_sd_configs: + - service: dedicated_server + endpoint: ovh-eu + application_key: testAppKey + consumer_key: testConsumerKey + refresh_interval: 1m diff --git a/discovery/install/install.go b/discovery/install/install.go index 36e13948d..f090076b7 100644 --- a/discovery/install/install.go +++ b/discovery/install/install.go @@ -33,6 +33,7 @@ import ( _ "github.com/prometheus/prometheus/discovery/moby" // register moby _ "github.com/prometheus/prometheus/discovery/nomad" // register nomad _ "github.com/prometheus/prometheus/discovery/openstack" // register openstack + _ "github.com/prometheus/prometheus/discovery/ovhcloud" // register ovhcloud _ "github.com/prometheus/prometheus/discovery/puppetdb" // register puppetdb _ "github.com/prometheus/prometheus/discovery/scaleway" // register scaleway _ "github.com/prometheus/prometheus/discovery/triton" // register triton diff --git a/discovery/ovhcloud/dedicated_server.go b/discovery/ovhcloud/dedicated_server.go new file mode 100644 index 000000000..db205cc83 --- /dev/null +++ b/discovery/ovhcloud/dedicated_server.go @@ -0,0 +1,161 @@ +// Copyright 2021 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 ovhcloud + +import ( + "context" + "fmt" + "net/netip" + "net/url" + "path" + "strconv" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/ovh/go-ovh/ovh" + "github.com/prometheus/common/model" + + "github.com/prometheus/prometheus/discovery/refresh" + "github.com/prometheus/prometheus/discovery/targetgroup" +) + +const ( + dedicatedServerAPIPath = "/dedicated/server" + dedicatedServerLabelPrefix = metaLabelPrefix + "dedicatedServer_" +) + +type dedicatedServer struct { + State string `json:"state"` + ips []netip.Addr + CommercialRange string `json:"commercialRange"` + LinkSpeed int `json:"linkSpeed"` + Rack string `json:"rack"` + NoIntervention bool `json:"noIntervention"` + Os string `json:"os"` + SupportLevel string `json:"supportLevel"` + ServerID int64 `json:"serverId"` + Reverse string `json:"reverse"` + Datacenter string `json:"datacenter"` + Name string `json:"name"` +} + +type dedicatedServerDiscovery struct { + *refresh.Discovery + config *SDConfig + logger log.Logger +} + +func newDedicatedServerDiscovery(conf *SDConfig, logger log.Logger) *dedicatedServerDiscovery { + return &dedicatedServerDiscovery{config: conf, logger: logger} +} + +func getDedicatedServerList(client *ovh.Client) ([]string, error) { + var dedicatedListName []string + err := client.Get(dedicatedServerAPIPath, &dedicatedListName) + if err != nil { + return nil, err + } + + return dedicatedListName, nil +} + +func getDedicatedServerDetails(client *ovh.Client, serverName string) (*dedicatedServer, error) { + var dedicatedServerDetails dedicatedServer + err := client.Get(path.Join(dedicatedServerAPIPath, url.QueryEscape(serverName)), &dedicatedServerDetails) + if err != nil { + return nil, err + } + + var ips []string + err = client.Get(path.Join(dedicatedServerAPIPath, url.QueryEscape(serverName), "ips"), &ips) + if err != nil { + return nil, err + } + + parsedIPs, err := parseIPList(ips) + if err != nil { + return nil, err + } + + dedicatedServerDetails.ips = parsedIPs + return &dedicatedServerDetails, nil +} + +func (d *dedicatedServerDiscovery) getService() string { + return "dedicated_server" +} + +func (d *dedicatedServerDiscovery) getSource() string { + return fmt.Sprintf("%s_%s", d.config.Name(), d.getService()) +} + +func (d *dedicatedServerDiscovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) { + client, err := createClient(d.config) + if err != nil { + return nil, err + } + var dedicatedServerDetailedList []dedicatedServer + dedicatedServerList, err := getDedicatedServerList(client) + if err != nil { + return nil, err + } + for _, dedicatedServerName := range dedicatedServerList { + dedicatedServer, err := getDedicatedServerDetails(client, dedicatedServerName) + if err != nil { + err := level.Warn(d.logger).Log("msg", fmt.Sprintf("%s: Could not get details of %s", d.getSource(), dedicatedServerName), "err", err.Error()) + if err != nil { + return nil, err + } + continue + } + dedicatedServerDetailedList = append(dedicatedServerDetailedList, *dedicatedServer) + } + var targets []model.LabelSet + + for _, server := range dedicatedServerDetailedList { + var ipv4, ipv6 string + for _, ip := range server.ips { + if ip.Is4() { + ipv4 = ip.String() + } + if ip.Is6() { + ipv6 = ip.String() + } + } + defaultIP := ipv4 + if defaultIP == "" { + defaultIP = ipv6 + } + labels := model.LabelSet{ + model.AddressLabel: model.LabelValue(defaultIP), + model.InstanceLabel: model.LabelValue(server.Name), + dedicatedServerLabelPrefix + "state": model.LabelValue(server.State), + dedicatedServerLabelPrefix + "commercialRange": model.LabelValue(server.CommercialRange), + dedicatedServerLabelPrefix + "linkSpeed": model.LabelValue(fmt.Sprintf("%d", server.LinkSpeed)), + dedicatedServerLabelPrefix + "rack": model.LabelValue(server.Rack), + dedicatedServerLabelPrefix + "noIntervention": model.LabelValue(strconv.FormatBool(server.NoIntervention)), + dedicatedServerLabelPrefix + "os": model.LabelValue(server.Os), + dedicatedServerLabelPrefix + "supportLevel": model.LabelValue(server.SupportLevel), + dedicatedServerLabelPrefix + "serverId": model.LabelValue(fmt.Sprintf("%d", server.ServerID)), + dedicatedServerLabelPrefix + "reverse": model.LabelValue(server.Reverse), + dedicatedServerLabelPrefix + "datacenter": model.LabelValue(server.Datacenter), + dedicatedServerLabelPrefix + "name": model.LabelValue(server.Name), + dedicatedServerLabelPrefix + "ipv4": model.LabelValue(ipv4), + dedicatedServerLabelPrefix + "ipv6": model.LabelValue(ipv6), + } + targets = append(targets, labels) + } + + return []*targetgroup.Group{{Source: d.getSource(), Targets: targets}}, nil +} diff --git a/discovery/ovhcloud/dedicated_server_test.go b/discovery/ovhcloud/dedicated_server_test.go new file mode 100644 index 000000000..280cd2631 --- /dev/null +++ b/discovery/ovhcloud/dedicated_server_test.go @@ -0,0 +1,123 @@ +// Copyright 2021 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 ovhcloud + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + "github.com/go-kit/log" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" +) + +func TestOvhcloudDedicatedServerRefresh(t *testing.T) { + var cfg SDConfig + + mock := httptest.NewServer(http.HandlerFunc(MockDedicatedAPI)) + defer mock.Close() + cfgString := fmt.Sprintf(` +--- +service: dedicated_server +endpoint: %s +application_key: %s +application_secret: %s +consumer_key: %s`, mock.URL, ovhcloudApplicationKeyTest, ovhcloudApplicationSecretTest, ovhcloudConsumerKeyTest) + + require.NoError(t, yaml.UnmarshalStrict([]byte(cfgString), &cfg)) + d, err := newRefresher(&cfg, log.NewNopLogger()) + require.NoError(t, err) + ctx := context.Background() + targetGroups, err := d.refresh(ctx) + require.NoError(t, err) + + require.Equal(t, 1, len(targetGroups)) + targetGroup := targetGroups[0] + require.NotNil(t, targetGroup) + require.NotNil(t, targetGroup.Targets) + require.Equal(t, 1, len(targetGroup.Targets)) + + for i, lbls := range []model.LabelSet{ + { + "__address__": "1.2.3.4", + "__meta_ovhcloud_dedicatedServer_commercialRange": "Advance-1 Gen 2", + "__meta_ovhcloud_dedicatedServer_datacenter": "gra3", + "__meta_ovhcloud_dedicatedServer_ipv4": "1.2.3.4", + "__meta_ovhcloud_dedicatedServer_ipv6": "", + "__meta_ovhcloud_dedicatedServer_linkSpeed": "123", + "__meta_ovhcloud_dedicatedServer_name": "abcde", + "__meta_ovhcloud_dedicatedServer_noIntervention": "false", + "__meta_ovhcloud_dedicatedServer_os": "debian11_64", + "__meta_ovhcloud_dedicatedServer_rack": "TESTRACK", + "__meta_ovhcloud_dedicatedServer_reverse": "abcde-rev", + "__meta_ovhcloud_dedicatedServer_serverId": "1234", + "__meta_ovhcloud_dedicatedServer_state": "test", + "__meta_ovhcloud_dedicatedServer_supportLevel": "pro", + "instance": "abcde", + }, + } { + t.Run(fmt.Sprintf("item %d", i), func(t *testing.T) { + require.Equal(t, lbls, targetGroup.Targets[i]) + }) + } +} + +func MockDedicatedAPI(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Ovh-Application") != ovhcloudApplicationKeyTest { + http.Error(w, "bad application key", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + if string(r.URL.Path) == "/dedicated/server" { + dedicatedServersList, err := os.ReadFile("testdata/dedicated_server/dedicated_servers.json") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(dedicatedServersList) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + if string(r.URL.Path) == "/dedicated/server/abcde" { + dedicatedServer, err := os.ReadFile("testdata/dedicated_server/dedicated_servers_details.json") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(dedicatedServer) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + if string(r.URL.Path) == "/dedicated/server/abcde/ips" { + dedicatedServerIPs, err := os.ReadFile("testdata/dedicated_server/dedicated_servers_abcde_ips.json") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(dedicatedServerIPs) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} diff --git a/discovery/ovhcloud/ovhcloud.go b/discovery/ovhcloud/ovhcloud.go new file mode 100644 index 000000000..9ac857542 --- /dev/null +++ b/discovery/ovhcloud/ovhcloud.go @@ -0,0 +1,155 @@ +// Copyright 2021 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 ovhcloud + +import ( + "context" + "errors" + "fmt" + "net/netip" + "time" + + "github.com/go-kit/log" + "github.com/ovh/go-ovh/ovh" + "github.com/prometheus/common/config" + "github.com/prometheus/common/model" + + "github.com/prometheus/prometheus/discovery" + "github.com/prometheus/prometheus/discovery/refresh" + "github.com/prometheus/prometheus/discovery/targetgroup" +) + +// metaLabelPrefix is the meta prefix used for all meta labels in this discovery. +const metaLabelPrefix = model.MetaLabelPrefix + "ovhcloud_" + +type refresher interface { + refresh(context.Context) ([]*targetgroup.Group, error) +} + +var DefaultSDConfig = SDConfig{ + Endpoint: "ovh-eu", + RefreshInterval: model.Duration(60 * time.Second), +} + +// SDConfig defines the Service Discovery struct used for configuration. +type SDConfig struct { + Endpoint string `yaml:"endpoint"` + ApplicationKey string `yaml:"application_key"` + ApplicationSecret config.Secret `yaml:"application_secret"` + ConsumerKey config.Secret `yaml:"consumer_key"` + RefreshInterval model.Duration `yaml:"refresh_interval"` + Service string `yaml:"service"` +} + +// Name implements the Discoverer interface. +func (c SDConfig) Name() string { + return "ovhcloud" +} + +// UnmarshalYAML implements the yaml.Unmarshaler interface. +func (c *SDConfig) UnmarshalYAML(unmarshal func(interface{}) error) error { + *c = DefaultSDConfig + type plain SDConfig + err := unmarshal((*plain)(c)) + if err != nil { + return err + } + + if c.Endpoint == "" { + return errors.New("endpoint can not be empty") + } + if c.ApplicationKey == "" { + return errors.New("application key can not be empty") + } + if c.ApplicationSecret == "" { + return errors.New("application secret can not be empty") + } + if c.ConsumerKey == "" { + return errors.New("consumer key can not be empty") + } + switch c.Service { + case "dedicated_server", "vps": + return nil + default: + return fmt.Errorf("unknown service: %v", c.Service) + } +} + +// CreateClient creates a new ovh client configured with given credentials. +func createClient(config *SDConfig) (*ovh.Client, error) { + return ovh.NewClient(config.Endpoint, config.ApplicationKey, string(config.ApplicationSecret), string(config.ConsumerKey)) +} + +// NewDiscoverer new discoverer +func (c *SDConfig) NewDiscoverer(options discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewDiscovery(c, options.Logger) +} + +func init() { + discovery.RegisterConfig(&SDConfig{}) +} + +// ParseIPList parses ip list as they can have different formats. +func parseIPList(ipList []string) ([]netip.Addr, error) { + var IPs []netip.Addr + for _, ip := range ipList { + ipAddr, err := netip.ParseAddr(ip) + if err != nil { + ipPrefix, err := netip.ParsePrefix(ip) + if err != nil { + return nil, errors.New("could not parse IP addresses from list") + } + if ipPrefix.IsValid() { + netmask := ipPrefix.Bits() + if netmask != 32 { + continue + } + ipAddr = ipPrefix.Addr() + } + } + if ipAddr.IsValid() && !ipAddr.IsUnspecified() { + IPs = append(IPs, ipAddr) + } + } + + if len(IPs) < 1 { + return nil, errors.New("could not parse IP addresses from list") + } + return IPs, nil +} + +func newRefresher(conf *SDConfig, logger log.Logger) (refresher, error) { + switch conf.Service { + case "vps": + return newVpsDiscovery(conf, logger), nil + case "dedicated_server": + return newDedicatedServerDiscovery(conf, logger), nil + } + return nil, fmt.Errorf("unknown OVHcloud discovery service '%s'", conf.Service) +} + +// NewDiscovery returns a new Ovhcloud Discoverer which periodically refreshes its targets. +func NewDiscovery(conf *SDConfig, logger log.Logger) (*refresh.Discovery, error) { + r, err := newRefresher(conf, logger) + if err != nil { + return nil, err + } + + return refresh.NewDiscovery( + logger, + "ovhcloud", + time.Duration(conf.RefreshInterval), + r.refresh, + ), nil +} diff --git a/discovery/ovhcloud/ovhcloud_test.go b/discovery/ovhcloud/ovhcloud_test.go new file mode 100644 index 000000000..efcd95bb0 --- /dev/null +++ b/discovery/ovhcloud/ovhcloud_test.go @@ -0,0 +1,129 @@ +// Copyright 2021 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 ovhcloud + +import ( + "errors" + "fmt" + "testing" + + "github.com/prometheus/common/config" + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v2" + + "github.com/prometheus/prometheus/discovery" + "github.com/prometheus/prometheus/util/testutil" +) + +var ( + ovhcloudApplicationKeyTest = "TDPKJdwZwAQPwKX2" + ovhcloudApplicationSecretTest = config.Secret("9ufkBmLaTQ9nz5yMUlg79taH0GNnzDjk") + ovhcloudConsumerKeyTest = config.Secret("5mBuy6SUQcRw2ZUxg0cG68BoDKpED4KY") +) + +const ( + mockURL = "https://localhost:1234" +) + +func getMockConf(service string) (SDConfig, error) { + confString := fmt.Sprintf(` +endpoint: %s +application_key: %s +application_secret: %s +consumer_key: %s +refresh_interval: 1m +service: %s +`, mockURL, ovhcloudApplicationKeyTest, ovhcloudApplicationSecretTest, ovhcloudConsumerKeyTest, service) + + return getMockConfFromString(confString) +} + +func getMockConfFromString(confString string) (SDConfig, error) { + var conf SDConfig + err := yaml.UnmarshalStrict([]byte(confString), &conf) + return conf, err +} + +func TestErrorInitClient(t *testing.T) { + confString := fmt.Sprintf(` +endpoint: %s + +`, mockURL) + + conf, _ := getMockConfFromString(confString) + + _, err := createClient(&conf) + + require.ErrorContains(t, err, "missing application key") +} + +func TestParseIPs(t *testing.T) { + testCases := []struct { + name string + input []string + want error + }{ + { + name: "Parse IPv4 failed.", + input: []string{"A.b"}, + want: errors.New("could not parse IP addresses from list"), + }, + { + name: "Parse unspecified failed.", + input: []string{"0.0.0.0"}, + want: errors.New("could not parse IP addresses from list"), + }, + { + name: "Parse void IP failed.", + input: []string{""}, + want: errors.New("could not parse IP addresses from list"), + }, + { + name: "Parse IPv6 ok.", + input: []string{"2001:0db8:0000:0000:0000:0000:0000:0001"}, + want: nil, + }, + { + name: "Parse IPv6 failed.", + input: []string{"bbb:cccc:1111"}, + want: errors.New("could not parse IP addresses from list"), + }, + { + name: "Parse IPv4 bad mask.", + input: []string{"192.0.2.1/23"}, + want: errors.New("could not parse IP addresses from list"), + }, + { + name: "Parse IPv4 ok.", + input: []string{"192.0.2.1/32"}, + want: nil, + }, + } + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + _, err := parseIPList(tc.input) + require.Equal(t, tc.want, err) + }) + } +} + +func TestDiscoverer(t *testing.T) { + conf, _ := getMockConf("vps") + logger := testutil.NewLogger(t) + _, err := conf.NewDiscoverer(discovery.DiscovererOptions{ + Logger: logger, + }) + + require.NoError(t, err) +} diff --git a/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers.json b/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers.json new file mode 100644 index 000000000..a250e9605 --- /dev/null +++ b/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers.json @@ -0,0 +1,3 @@ +[ + "abcde" +] \ No newline at end of file diff --git a/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers_abcde_ips.json b/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers_abcde_ips.json new file mode 100644 index 000000000..a1b949c97 --- /dev/null +++ b/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers_abcde_ips.json @@ -0,0 +1,4 @@ +[ + "1.2.3.4/32", + "2001:0db8:0000:0000:0000:0000:0000:0001/64" +] \ No newline at end of file diff --git a/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers_details.json b/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers_details.json new file mode 100644 index 000000000..68f3e3b5c --- /dev/null +++ b/discovery/ovhcloud/testdata/dedicated_server/dedicated_servers_details.json @@ -0,0 +1,20 @@ +{ + "ip": "1.2.3.4", + "newUpgradeSystem": true, + "commercialRange": "Advance-1 Gen 2", + "rack": "TESTRACK", + "rescueMail": null, + "supportLevel": "pro", + "bootId": 1, + "linkSpeed": 123, + "professionalUse": false, + "monitoring": true, + "noIntervention": false, + "name": "abcde", + "rootDevice": null, + "state": "test", + "datacenter": "gra3", + "os": "debian11_64", + "reverse": "abcde-rev", + "serverId": 1234 +} \ No newline at end of file diff --git a/discovery/ovhcloud/testdata/vps/vps.json b/discovery/ovhcloud/testdata/vps/vps.json new file mode 100644 index 000000000..18147865b --- /dev/null +++ b/discovery/ovhcloud/testdata/vps/vps.json @@ -0,0 +1,3 @@ +[ + "abc" +] \ No newline at end of file diff --git a/discovery/ovhcloud/testdata/vps/vps_abc_ips.json b/discovery/ovhcloud/testdata/vps/vps_abc_ips.json new file mode 100644 index 000000000..3c871d093 --- /dev/null +++ b/discovery/ovhcloud/testdata/vps/vps_abc_ips.json @@ -0,0 +1,4 @@ +[ + "192.0.2.1/32", + "2001:0db1:0000:0000:0000:0000:0000:0001/64" +] \ No newline at end of file diff --git a/discovery/ovhcloud/testdata/vps/vps_details.json b/discovery/ovhcloud/testdata/vps/vps_details.json new file mode 100644 index 000000000..f2bb2e803 --- /dev/null +++ b/discovery/ovhcloud/testdata/vps/vps_details.json @@ -0,0 +1,25 @@ +{ + "offerType": "ssd", + "monitoringIpBlocks": [], + "displayName": "abc", + "zone": "zone", + "cluster": "cluster_test", + "slaMonitoring": false, + "name": "abc", + "vcore": 1, + "state": "running", + "keymap": null, + "netbootMode": "local", + "model": { + "name": "vps-value-1-2-40", + "availableOptions": [], + "maximumAdditionnalIp": 16, + "offer": "VPS abc", + "disk": 40, + "version": "2019v1", + "vcore": 1, + "memory": 2048, + "datacenter": [] + }, + "memoryLimit": 2048 +} \ No newline at end of file diff --git a/discovery/ovhcloud/vps.go b/discovery/ovhcloud/vps.go new file mode 100644 index 000000000..414d0ac92 --- /dev/null +++ b/discovery/ovhcloud/vps.go @@ -0,0 +1,186 @@ +// Copyright 2021 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 ovhcloud + +import ( + "context" + "fmt" + "net/netip" + "net/url" + "path" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/ovh/go-ovh/ovh" + "github.com/prometheus/common/model" + + "github.com/prometheus/prometheus/discovery/refresh" + "github.com/prometheus/prometheus/discovery/targetgroup" +) + +const ( + vpsAPIPath = "/vps" + vpsLabelPrefix = metaLabelPrefix + "vps_" +) + +// Model struct from API. +type vpsModel struct { + MaximumAdditionalIP int `json:"maximumAdditionnalIp"` + Offer string `json:"offer"` + Datacenter []string `json:"datacenter"` + Vcore int `json:"vcore"` + Version string `json:"version"` + Name string `json:"name"` + Disk int `json:"disk"` + Memory int `json:"memory"` +} + +// VPS struct from API. +type virtualPrivateServer struct { + ips []netip.Addr + Keymap []string `json:"keymap"` + Zone string `json:"zone"` + Model vpsModel `json:"model"` + DisplayName string `json:"displayName"` + MonitoringIPBlocks []string `json:"monitoringIpBlocks"` + Cluster string `json:"cluster"` + State string `json:"state"` + Name string `json:"name"` + NetbootMode string `json:"netbootMode"` + MemoryLimit int `json:"memoryLimit"` + OfferType string `json:"offerType"` + Vcore int `json:"vcore"` +} + +type vpsDiscovery struct { + *refresh.Discovery + config *SDConfig + logger log.Logger +} + +func newVpsDiscovery(conf *SDConfig, logger log.Logger) *vpsDiscovery { + return &vpsDiscovery{config: conf, logger: logger} +} + +func getVpsDetails(client *ovh.Client, vpsName string) (*virtualPrivateServer, error) { + var vpsDetails virtualPrivateServer + vpsNamePath := path.Join(vpsAPIPath, url.QueryEscape(vpsName)) + + err := client.Get(vpsNamePath, &vpsDetails) + if err != nil { + return nil, err + } + + var ips []string + err = client.Get(path.Join(vpsNamePath, "ips"), &ips) + if err != nil { + return nil, err + } + + parsedIPs, err := parseIPList(ips) + if err != nil { + return nil, err + } + vpsDetails.ips = parsedIPs + + return &vpsDetails, nil +} + +func getVpsList(client *ovh.Client) ([]string, error) { + var vpsListName []string + + err := client.Get(vpsAPIPath, &vpsListName) + if err != nil { + return nil, err + } + + return vpsListName, nil +} + +func (d *vpsDiscovery) getService() string { + return "vps" +} + +func (d *vpsDiscovery) getSource() string { + return fmt.Sprintf("%s_%s", d.config.Name(), d.getService()) +} + +func (d *vpsDiscovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) { + client, err := createClient(d.config) + if err != nil { + return nil, err + } + + var vpsDetailedList []virtualPrivateServer + vpsList, err := getVpsList(client) + if err != nil { + return nil, err + } + + for _, vpsName := range vpsList { + vpsDetailed, err := getVpsDetails(client, vpsName) + if err != nil { + err := level.Warn(d.logger).Log("msg", fmt.Sprintf("%s: Could not get details of %s", d.getSource(), vpsName), "err", err.Error()) + if err != nil { + return nil, err + } + continue + } + vpsDetailedList = append(vpsDetailedList, *vpsDetailed) + } + + var targets []model.LabelSet + for _, server := range vpsDetailedList { + var ipv4, ipv6 string + for _, ip := range server.ips { + if ip.Is4() { + ipv4 = ip.String() + } + if ip.Is6() { + ipv6 = ip.String() + } + } + defaultIP := ipv4 + if defaultIP == "" { + defaultIP = ipv6 + } + labels := model.LabelSet{ + model.AddressLabel: model.LabelValue(defaultIP), + model.InstanceLabel: model.LabelValue(server.Name), + vpsLabelPrefix + "offer": model.LabelValue(server.Model.Offer), + vpsLabelPrefix + "datacenter": model.LabelValue(fmt.Sprintf("%+v", server.Model.Datacenter)), + vpsLabelPrefix + "model_vcore": model.LabelValue(fmt.Sprintf("%d", server.Model.Vcore)), + vpsLabelPrefix + "maximumAdditionalIp": model.LabelValue(fmt.Sprintf("%d", server.Model.MaximumAdditionalIP)), + vpsLabelPrefix + "version": model.LabelValue(server.Model.Version), + vpsLabelPrefix + "model_name": model.LabelValue(server.Model.Name), + vpsLabelPrefix + "disk": model.LabelValue(fmt.Sprintf("%d", server.Model.Disk)), + vpsLabelPrefix + "memory": model.LabelValue(fmt.Sprintf("%d", server.Model.Memory)), + vpsLabelPrefix + "zone": model.LabelValue(server.Zone), + vpsLabelPrefix + "displayName": model.LabelValue(server.DisplayName), + vpsLabelPrefix + "cluster": model.LabelValue(server.Cluster), + vpsLabelPrefix + "state": model.LabelValue(server.State), + vpsLabelPrefix + "name": model.LabelValue(server.Name), + vpsLabelPrefix + "netbootMode": model.LabelValue(server.NetbootMode), + vpsLabelPrefix + "memoryLimit": model.LabelValue(fmt.Sprintf("%d", server.MemoryLimit)), + vpsLabelPrefix + "offerType": model.LabelValue(server.OfferType), + vpsLabelPrefix + "vcore": model.LabelValue(fmt.Sprintf("%d", server.Vcore)), + vpsLabelPrefix + "ipv4": model.LabelValue(ipv4), + vpsLabelPrefix + "ipv6": model.LabelValue(ipv6), + } + + targets = append(targets, labels) + } + + return []*targetgroup.Group{{Source: d.getSource(), Targets: targets}}, nil +} diff --git a/discovery/ovhcloud/vps_test.go b/discovery/ovhcloud/vps_test.go new file mode 100644 index 000000000..3bbb42e33 --- /dev/null +++ b/discovery/ovhcloud/vps_test.go @@ -0,0 +1,130 @@ +// Copyright 2021 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 ovhcloud + +import ( + "context" + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + yaml "gopkg.in/yaml.v2" + + "github.com/go-kit/log" + "github.com/prometheus/common/model" + "github.com/stretchr/testify/require" +) + +func TestOvhCloudVpsRefresh(t *testing.T) { + var cfg SDConfig + + mock := httptest.NewServer(http.HandlerFunc(MockVpsAPI)) + defer mock.Close() + cfgString := fmt.Sprintf(` +--- +service: vps +endpoint: %s +application_key: %s +application_secret: %s +consumer_key: %s`, mock.URL, ovhcloudApplicationKeyTest, ovhcloudApplicationSecretTest, ovhcloudConsumerKeyTest) + + require.NoError(t, yaml.UnmarshalStrict([]byte(cfgString), &cfg)) + + d, err := newRefresher(&cfg, log.NewNopLogger()) + require.NoError(t, err) + ctx := context.Background() + targetGroups, err := d.refresh(ctx) + require.NoError(t, err) + + require.Equal(t, 1, len(targetGroups)) + targetGroup := targetGroups[0] + require.NotNil(t, targetGroup) + require.NotNil(t, targetGroup.Targets) + require.Equal(t, 1, len(targetGroup.Targets)) + for i, lbls := range []model.LabelSet{ + { + "__address__": "192.0.2.1", + "__meta_ovhcloud_vps_ipv4": "192.0.2.1", + "__meta_ovhcloud_vps_ipv6": "", + "__meta_ovhcloud_vps_cluster": "cluster_test", + "__meta_ovhcloud_vps_datacenter": "[]", + "__meta_ovhcloud_vps_disk": "40", + "__meta_ovhcloud_vps_displayName": "abc", + "__meta_ovhcloud_vps_maximumAdditionalIp": "16", + "__meta_ovhcloud_vps_memory": "2048", + "__meta_ovhcloud_vps_memoryLimit": "2048", + "__meta_ovhcloud_vps_model_name": "vps-value-1-2-40", + "__meta_ovhcloud_vps_name": "abc", + "__meta_ovhcloud_vps_netbootMode": "local", + "__meta_ovhcloud_vps_offer": "VPS abc", + "__meta_ovhcloud_vps_offerType": "ssd", + "__meta_ovhcloud_vps_state": "running", + "__meta_ovhcloud_vps_vcore": "1", + "__meta_ovhcloud_vps_model_vcore": "1", + "__meta_ovhcloud_vps_version": "2019v1", + "__meta_ovhcloud_vps_zone": "zone", + "instance": "abc", + }, + } { + t.Run(fmt.Sprintf("item %d", i), func(t *testing.T) { + require.Equal(t, lbls, targetGroup.Targets[i]) + }) + } +} + +func MockVpsAPI(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-Ovh-Application") != ovhcloudApplicationKeyTest { + http.Error(w, "bad application key", http.StatusBadRequest) + return + } + w.Header().Set("Content-Type", "application/json") + if string(r.URL.Path) == "/vps" { + dedicatedServersList, err := os.ReadFile("testdata/vps/vps.json") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(dedicatedServersList) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + if string(r.URL.Path) == "/vps/abc" { + dedicatedServer, err := os.ReadFile("testdata/vps/vps_details.json") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(dedicatedServer) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } + if string(r.URL.Path) == "/vps/abc/ips" { + dedicatedServerIPs, err := os.ReadFile("testdata/vps/vps_abc_ips.json") + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + _, err = w.Write(dedicatedServerIPs) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + } +} diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 009066c83..23de16fbf 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -294,6 +294,10 @@ nomad_sd_configs: openstack_sd_configs: [ - ... ] +# List of OVHcloud service discovery configurations. +ovhcloud_sd_configs: + [ - ... ] + # List of PuppetDB service discovery configurations. puppetdb_sd_configs: [ - ... ] @@ -1176,6 +1180,68 @@ tls_config: [ ] ``` +### `` + +OVHcloud SD configurations allow retrieving scrape targets from OVHcloud's [dedicated servers](https://www.ovhcloud.com/en/bare-metal/) and [VPS](https://www.ovhcloud.com/en/vps/) using +their [API](https://api.ovh.com/). +Prometheus will periodically check the REST endpoint and create a target for every discovered server. +The role will try to use the public IPv4 address as default address, if there's none it will try to use the IPv6 one. This may be changed with relabeling. +For OVHcloud's [public cloud instances](https://www.ovhcloud.com/en/public-cloud/) you can use the [openstack_sd_config](#openstack_sd_config). + +#### VPS + +* `__meta_ovhcloud_vps_ipv4`: the ipv4 of the server +* `__meta_ovhcloud_vps_ipv6`: the ipv6 of the server +* `__meta_ovhcloud_vps_keymap`: the KVM keyboard layout on VPS Cloud +* `__meta_ovhcloud_vps_zone`: the zone of the server +* `__meta_ovhcloud_vps_maximumAdditionalIp`: the maximumAdditionalIp of the server +* `__meta_ovhcloud_vps_offer`: the offer of the server +* `__meta_ovhcloud_vps_datacenter`: the datacenter of the server +* `__meta_ovhcloud_vps_vcore`: the vcore of the server +* `__meta_ovhcloud_vps_version`: the version of the server +* `__meta_ovhcloud_vps_name`: the name of the server +* `__meta_ovhcloud_vps_disk`: the disk of the server +* `__meta_ovhcloud_vps_memory`: the memory of the server +* `__meta_ovhcloud_vps_displayName`: the name displayed in ManagerV6 for your VPS +* `__meta_ovhcloud_vps_monitoringIpBlocks`: the Ip blocks for OVH monitoring servers +* `__meta_ovhcloud_vps_cluster`: the cluster of the server +* `__meta_ovhcloud_vps_state`: the state of the server +* `__meta_ovhcloud_vps_name`: the name of the server +* `__meta_ovhcloud_vps_netbootMode`: the netbootMode of the server +* `__meta_ovhcloud_vps_memoryLimit`: the memoryLimit of the server +* `__meta_ovhcloud_vps_offerType`: the offerType of the server +* `__meta_ovhcloud_vps_vcore`: the vcore of the server + +#### Dedicated servers + +* `__meta_ovhcloud_dedicated_server_state`: the state of the server +* `__meta_ovhcloud_dedicated_server_ipv4`: the ipv4 of the server +* `__meta_ovhcloud_dedicated_server_ipv6`: the ipv6 of the server +* `__meta_ovhcloud_dedicated_server_commercialRange`: the dedicated server commercial range +* `__meta_ovhcloud_dedicated_server_linkSpeed`: the linkSpeed of the server +* `__meta_ovhcloud_dedicated_server_rack`: the rack of the server +* `__meta_ovhcloud_dedicated_server_os`: operating system +* `__meta_ovhcloud_dedicated_server_supportLevel`: the supportLevel of the server +* `__meta_ovhcloud_dedicated_server_serverId`: your server id +* `__meta_ovhcloud_dedicated_server_reverse`: dedicated server reverse +* `__meta_ovhcloud_dedicated_server_datacenter`: the dedicated datacenter localisation +* `__meta_ovhcloud_dedicated_server_name`: the dedicated server name + +See below for the configuration options for OVHcloud discovery: + +```yaml +# Access key to use. https://api.ovh.com +application_key: +application_secret: +consumer_key: +# Service of the targets to retrieve. Must be `vps` or `dedicated_server`. +service: +# API endpoint. https://github.com/ovh/go-ovh#supported-apis +[ endpoint: | default = "ovh-eu" ] +# Refresh interval to re-read the resources list. +[ refresh_interval: | default = 60s ] +``` + ### `` PuppetDB SD configurations allow retrieving scrape targets from @@ -2965,6 +3031,10 @@ nomad_sd_configs: openstack_sd_configs: [ - ... ] +# List of OVHcloud service discovery configurations. +ovhcloud_sd_configs: + [ - ... ] + # List of PuppetDB service discovery configurations. puppetdb_sd_configs: [ - ... ] diff --git a/documentation/examples/prometheus-ovhcloud.yml b/documentation/examples/prometheus-ovhcloud.yml new file mode 100644 index 000000000..21facad1c --- /dev/null +++ b/documentation/examples/prometheus-ovhcloud.yml @@ -0,0 +1,16 @@ +# An example scrape configuration for running Prometheus with Ovhcloud. +scrape_configs: + - job_name: 'ovhcloud' + ovhcloud_sd_configs: + - service: vps + endpoint: ovh-eu + application_key: XXX + application_secret: XXX + consumer_key: XXX + refresh_interval: 1m + - service: dedicated_server + endpoint: ovh-eu + application_key: XXX + application_secret: XXX + consumer_key: XXX + refresh_interval: 1m diff --git a/go.mod b/go.mod index 37ae9e406..d8a8704a4 100644 --- a/go.mod +++ b/go.mod @@ -38,6 +38,7 @@ require ( github.com/mwitkow/go-conntrack v0.0.0-20190716064945-2f068394615f github.com/oklog/run v1.1.0 github.com/oklog/ulid v1.3.1 + github.com/ovh/go-ovh v1.1.0 github.com/pkg/errors v0.9.1 github.com/prometheus/alertmanager v0.24.0 github.com/prometheus/client_golang v1.13.0 diff --git a/go.sum b/go.sum index 1bbb45fa7..ccdd349b0 100644 --- a/go.sum +++ b/go.sum @@ -677,6 +677,8 @@ github.com/openzipkin-contrib/zipkin-go-opentracing v0.4.5/go.mod h1:/wsWhb9smxS github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/openzipkin/zipkin-go v0.2.1/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= github.com/openzipkin/zipkin-go v0.2.2/go.mod h1:NaW6tEwdmWMaCDZzg8sh+IBNOxHMPnhQw8ySjnjRyN4= +github.com/ovh/go-ovh v1.1.0 h1:bHXZmw8nTgZin4Nv7JuaLs0KG5x54EQR7migYTd1zrk= +github.com/ovh/go-ovh v1.1.0/go.mod h1:AxitLZ5HBRPyUd+Zl60Ajaag+rNTdVXWIkzfrVuTXWA= github.com/pact-foundation/pact-go v1.0.4/go.mod h1:uExwJY4kCzNPcHRj+hCR/HBbOOIwwtUjcrb0b5/5kLM= github.com/pascaldekloe/goe v0.0.0-20180627143212-57f6aae5913c/go.mod h1:lzWF7FIEvWOWxwDKqyGYQf6ZUaNfKdP144TG7ZOy1lc= github.com/pascaldekloe/goe v0.1.0 h1:cBOtyMzM9HTpWjXfbbunk26uA6nG3a8n06Wieeh0MwY= @@ -1445,6 +1447,7 @@ gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMy gopkg.in/gcfg.v1 v1.2.3/go.mod h1:yesOnuUOFQAhST5vPY4nbZsb/huCgGGXlipJsBn0b3o= gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= +gopkg.in/ini.v1 v1.57.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/ini.v1 v1.66.6 h1:LATuAqN/shcYAOkv3wl2L4rkaKqkcgTBQjOyYDvcPKI= gopkg.in/ini.v1 v1.66.6/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k= gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo= diff --git a/plugins.yml b/plugins.yml index c83c806db..c10dabddb 100644 --- a/plugins.yml +++ b/plugins.yml @@ -13,6 +13,7 @@ - github.com/prometheus/prometheus/discovery/moby - github.com/prometheus/prometheus/discovery/nomad - github.com/prometheus/prometheus/discovery/openstack +- github.com/prometheus/prometheus/discovery/ovhcloud - github.com/prometheus/prometheus/discovery/puppetdb - github.com/prometheus/prometheus/discovery/scaleway - github.com/prometheus/prometheus/discovery/triton diff --git a/plugins/plugins.go b/plugins/plugins.go index 60d8d791b..e6994e206 100644 --- a/plugins/plugins.go +++ b/plugins/plugins.go @@ -61,6 +61,9 @@ import ( // Register openstack plugin. _ "github.com/prometheus/prometheus/discovery/openstack" + // Register ovhcloud plugin. + _ "github.com/prometheus/prometheus/discovery/ovhcloud" + // Register puppetdb plugin. _ "github.com/prometheus/prometheus/discovery/puppetdb"