From cda2dbbef67e2e98e059c4d25d840032d63badb7 Mon Sep 17 00:00:00 2001 From: Witek Bedyk Date: Tue, 19 Oct 2021 01:00:44 +0200 Subject: [PATCH] Add Uyuni service discovery (#8190) * Add Uyuni service discovery Signed-off-by: Witek Bedyk Co-authored-by: Joao Cavalheiro Co-authored-by: Marcelo Chiaradia Co-authored-by: Stefano Torresi Co-authored-by: Julien Pivotto --- config/config_test.go | 23 +- config/testdata/conf.good.yml | 6 + discovery/install/install.go | 1 + discovery/uyuni/uyuni.go | 341 ++++++++++++++++++++ docs/configuration/configuration.md | 81 +++++ documentation/examples/prometheus-uyuni.yml | 36 +++ go.mod | 1 + go.sum | 2 + 8 files changed, 490 insertions(+), 1 deletion(-) create mode 100644 discovery/uyuni/uyuni.go create mode 100644 documentation/examples/prometheus-uyuni.yml diff --git a/config/config_test.go b/config/config_test.go index 6db46a2b5..3055de74d 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -49,6 +49,7 @@ import ( "github.com/prometheus/prometheus/discovery/scaleway" "github.com/prometheus/prometheus/discovery/targetgroup" "github.com/prometheus/prometheus/discovery/triton" + "github.com/prometheus/prometheus/discovery/uyuni" "github.com/prometheus/prometheus/discovery/xds" "github.com/prometheus/prometheus/discovery/zookeeper" "github.com/prometheus/prometheus/pkg/labels" @@ -934,6 +935,26 @@ var expectedConf = &Config{ }, }, }, + { + JobName: "uyuni", + + HonorTimestamps: true, + ScrapeInterval: model.Duration(15 * time.Second), + ScrapeTimeout: DefaultGlobalConfig.ScrapeTimeout, + HTTPClientConfig: config.HTTPClientConfig{FollowRedirects: true}, + MetricsPath: DefaultScrapeConfig.MetricsPath, + Scheme: DefaultScrapeConfig.Scheme, + ServiceDiscoveryConfigs: discovery.Configs{ + &uyuni.SDConfig{ + Server: kubernetesSDHostURL(), + Username: "gopher", + Password: "hole", + Entitlement: "monitoring_entitled", + Separator: ",", + RefreshInterval: model.Duration(60 * time.Second), + }, + }, + }, }, AlertingConfig: AlertingConfig{ AlertmanagerConfigs: []*AlertmanagerConfig{ @@ -1018,7 +1039,7 @@ func TestElideSecrets(t *testing.T) { yamlConfig := string(config) matches := secretRe.FindAllStringIndex(yamlConfig, -1) - require.Equal(t, 15, len(matches), "wrong number of secret matches found") + require.Equal(t, 16, len(matches), "wrong number of secret matches found") require.NotContains(t, yamlConfig, "mysecret", "yaml marshal reveals authentication credentials.") } diff --git a/config/testdata/conf.good.yml b/config/testdata/conf.good.yml index a439bd0a0..cdd0c0b30 100644 --- a/config/testdata/conf.good.yml +++ b/config/testdata/conf.good.yml @@ -349,6 +349,12 @@ scrape_configs: - authorization: credentials: abcdef + - job_name: uyuni + uyuni_sd_configs: + - server: https://localhost:1234 + username: gopher + password: hole + alerting: alertmanagers: - scheme: https diff --git a/discovery/install/install.go b/discovery/install/install.go index 88cf67ca7..e16b348f6 100644 --- a/discovery/install/install.go +++ b/discovery/install/install.go @@ -34,6 +34,7 @@ import ( _ "github.com/prometheus/prometheus/discovery/puppetdb" // register puppetdb _ "github.com/prometheus/prometheus/discovery/scaleway" // register scaleway _ "github.com/prometheus/prometheus/discovery/triton" // register triton + _ "github.com/prometheus/prometheus/discovery/uyuni" // register uyuni _ "github.com/prometheus/prometheus/discovery/xds" // register xds _ "github.com/prometheus/prometheus/discovery/zookeeper" // register zookeeper ) diff --git a/discovery/uyuni/uyuni.go b/discovery/uyuni/uyuni.go new file mode 100644 index 000000000..080f17a8c --- /dev/null +++ b/discovery/uyuni/uyuni.go @@ -0,0 +1,341 @@ +// 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 uyuni + +import ( + "context" + "fmt" + "net/http" + "net/url" + "path" + "strings" + "time" + + "github.com/go-kit/log" + "github.com/go-kit/log/level" + "github.com/kolo/xmlrpc" + "github.com/pkg/errors" + "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" +) + +const ( + uyuniXMLRPCAPIPath = "/rpc/api" + + uyuniMetaLabelPrefix = model.MetaLabelPrefix + "uyuni_" + uyuniLabelMinionHostname = uyuniMetaLabelPrefix + "minion_hostname" + uyuniLabelPrimaryFQDN = uyuniMetaLabelPrefix + "primary_fqdn" + uyuniLablelSystemID = uyuniMetaLabelPrefix + "system_id" + uyuniLablelGroups = uyuniMetaLabelPrefix + "groups" + uyuniLablelEndpointName = uyuniMetaLabelPrefix + "endpoint_name" + uyuniLablelExporter = uyuniMetaLabelPrefix + "exporter" + uyuniLabelProxyModule = uyuniMetaLabelPrefix + "proxy_module" + uyuniLabelMetricsPath = uyuniMetaLabelPrefix + "metrics_path" + uyuniLabelScheme = uyuniMetaLabelPrefix + "scheme" +) + +// DefaultSDConfig is the default Uyuni SD configuration. +var DefaultSDConfig = SDConfig{ + Entitlement: "monitoring_entitled", + Separator: ",", + RefreshInterval: model.Duration(1 * time.Minute), +} + +func init() { + discovery.RegisterConfig(&SDConfig{}) +} + +// SDConfig is the configuration for Uyuni based service discovery. +type SDConfig struct { + Server config.URL `yaml:"server"` + Username string `yaml:"username"` + Password config.Secret `yaml:"password"` + HTTPClientConfig config.HTTPClientConfig `yaml:",inline"` + Entitlement string `yaml:"entitlement,omitempty"` + Separator string `yaml:"separator,omitempty"` + RefreshInterval model.Duration `yaml:"refresh_interval,omitempty"` +} + +// Uyuni API Response structures +type systemGroupID struct { + GroupID int `xmlrpc:"id"` + GroupName string `xmlrpc:"name"` +} + +type networkInfo struct { + SystemID int `xmlrpc:"system_id"` + Hostname string `xmlrpc:"hostname"` + PrimaryFQDN string `xmlrpc:"primary_fqdn"` + IP string `xmlrpc:"ip"` +} + +type endpointInfo struct { + SystemID int `xmlrpc:"system_id"` + EndpointName string `xmlrpc:"endpoint_name"` + Port int `xmlrpc:"port"` + Path string `xmlrpc:"path"` + Module string `xmlrpc:"module"` + ExporterName string `xmlrpc:"exporter_name"` + TLSEnabled bool `xmlrpc:"tls_enabled"` +} + +// Discovery periodically performs Uyuni API requests. It implements the Discoverer interface. +type Discovery struct { + *refresh.Discovery + apiURL *url.URL + roundTripper http.RoundTripper + username string + password string + entitlement string + separator string + interval time.Duration + logger log.Logger +} + +// Name returns the name of the Config. +func (*SDConfig) Name() string { return "uyuni" } + +// NewDiscoverer returns a Discoverer for the Config. +func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) { + return NewDiscovery(c, opts.Logger) +} + +// 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.Server.URL == nil { + return errors.New("Uyuni SD configuration requires server host") + } + + _, err = url.Parse(c.Server.String()) + if err != nil { + return errors.Wrap(err, "Uyuni Server URL is not valid") + } + + if c.Username == "" { + return errors.New("Uyuni SD configuration requires a username") + } + if c.Password == "" { + return errors.New("Uyuni SD configuration requires a password") + } + return nil +} + +// Attempt to login in Uyuni Server and get an auth token +func login(rpcclient *xmlrpc.Client, user string, pass string) (string, error) { + var result string + err := rpcclient.Call("auth.login", []interface{}{user, pass}, &result) + return result, err +} + +// Logout from Uyuni API +func logout(rpcclient *xmlrpc.Client, token string) error { + return rpcclient.Call("auth.logout", token, nil) +} + +// Get the system groups information of monitored clients +func getSystemGroupsInfoOfMonitoredClients(rpcclient *xmlrpc.Client, token string, entitlement string) (map[int][]systemGroupID, error) { + var systemGroupsInfos []struct { + SystemID int `xmlrpc:"id"` + SystemGroups []systemGroupID `xmlrpc:"system_groups"` + } + + err := rpcclient.Call("system.listSystemGroupsForSystemsWithEntitlement", []interface{}{token, entitlement}, &systemGroupsInfos) + if err != nil { + return nil, err + } + + result := make(map[int][]systemGroupID) + for _, systemGroupsInfo := range systemGroupsInfos { + result[systemGroupsInfo.SystemID] = systemGroupsInfo.SystemGroups + } + return result, nil +} + +// GetSystemNetworkInfo lists client FQDNs. +func getNetworkInformationForSystems(rpcclient *xmlrpc.Client, token string, systemIDs []int) (map[int]networkInfo, error) { + var networkInfos []networkInfo + err := rpcclient.Call("system.getNetworkForSystems", []interface{}{token, systemIDs}, &networkInfos) + if err != nil { + return nil, err + } + + result := make(map[int]networkInfo) + for _, networkInfo := range networkInfos { + result[networkInfo.SystemID] = networkInfo + } + return result, nil +} + +// Get endpoints information for given systems +func getEndpointInfoForSystems( + rpcclient *xmlrpc.Client, + token string, + systemIDs []int, +) ([]endpointInfo, error) { + var endpointInfos []endpointInfo + err := rpcclient.Call( + "system.monitoring.listEndpoints", + []interface{}{token, systemIDs}, &endpointInfos) + if err != nil { + return nil, err + } + return endpointInfos, err +} + +// NewDiscovery returns a uyuni discovery for the given configuration. +func NewDiscovery(conf *SDConfig, logger log.Logger) (*Discovery, error) { + var apiURL *url.URL + *apiURL = *conf.Server.URL + apiURL.Path = path.Join(apiURL.Path, uyuniXMLRPCAPIPath) + + rt, err := config.NewRoundTripperFromConfig(conf.HTTPClientConfig, "uyuni_sd", config.WithHTTP2Disabled()) + if err != nil { + return nil, err + } + + d := &Discovery{ + apiURL: apiURL, + roundTripper: rt, + username: conf.Username, + password: string(conf.Password), + entitlement: conf.Entitlement, + separator: conf.Separator, + interval: time.Duration(conf.RefreshInterval), + logger: logger, + } + + d.Discovery = refresh.NewDiscovery( + logger, + "uyuni", + time.Duration(conf.RefreshInterval), + d.refresh, + ) + return d, nil +} + +func (d *Discovery) getEndpointLabels( + endpoint endpointInfo, + systemGroupIDs []systemGroupID, + networkInfo networkInfo, +) model.LabelSet { + + var addr, scheme string + managedGroupNames := getSystemGroupNames(systemGroupIDs) + addr = fmt.Sprintf("%s:%d", networkInfo.Hostname, endpoint.Port) + if endpoint.TLSEnabled { + scheme = "https" + } else { + scheme = "http" + } + + result := model.LabelSet{ + model.AddressLabel: model.LabelValue(addr), + uyuniLabelMinionHostname: model.LabelValue(networkInfo.Hostname), + uyuniLabelPrimaryFQDN: model.LabelValue(networkInfo.PrimaryFQDN), + uyuniLablelSystemID: model.LabelValue(fmt.Sprintf("%d", endpoint.SystemID)), + uyuniLablelGroups: model.LabelValue(strings.Join(managedGroupNames, d.separator)), + uyuniLablelEndpointName: model.LabelValue(endpoint.EndpointName), + uyuniLablelExporter: model.LabelValue(endpoint.ExporterName), + uyuniLabelProxyModule: model.LabelValue(endpoint.Module), + uyuniLabelMetricsPath: model.LabelValue(endpoint.Path), + uyuniLabelScheme: model.LabelValue(scheme), + } + + return result +} + +func getSystemGroupNames(systemGroupsIDs []systemGroupID) []string { + managedGroupNames := make([]string, 0, len(systemGroupsIDs)) + for _, systemGroupInfo := range systemGroupsIDs { + managedGroupNames = append(managedGroupNames, systemGroupInfo.GroupName) + } + + return managedGroupNames +} + +func (d *Discovery) getTargetsForSystems( + rpcClient *xmlrpc.Client, + token string, + entitlement string, +) ([]model.LabelSet, error) { + + result := make([]model.LabelSet, 0) + + systemGroupIDsBySystemID, err := getSystemGroupsInfoOfMonitoredClients(rpcClient, token, entitlement) + if err != nil { + return nil, errors.Wrap(err, "unable to get the managed system groups information of monitored clients") + } + + systemIDs := make([]int, 0, len(systemGroupIDsBySystemID)) + for systemID := range systemGroupIDsBySystemID { + systemIDs = append(systemIDs, systemID) + } + + endpointInfos, err := getEndpointInfoForSystems(rpcClient, token, systemIDs) + if err != nil { + return nil, errors.Wrap(err, "unable to get endpoints information") + } + + networkInfoBySystemID, err := getNetworkInformationForSystems(rpcClient, token, systemIDs) + if err != nil { + return nil, errors.Wrap(err, "unable to get the systems network information") + } + + for _, endpoint := range endpointInfos { + systemID := endpoint.SystemID + labels := d.getEndpointLabels( + endpoint, + systemGroupIDsBySystemID[systemID], + networkInfoBySystemID[systemID]) + result = append(result, labels) + } + + return result, nil +} + +func (d *Discovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) { + rpcClient, err := xmlrpc.NewClient(d.apiURL.String(), d.roundTripper) + if err != nil { + return nil, err + } + defer rpcClient.Close() + + token, err := login(rpcClient, d.username, d.password) + if err != nil { + return nil, errors.Wrap(err, "unable to login to Uyuni API") + } + defer func() { + if err := logout(rpcClient, token); err != nil { + level.Debug(d.logger).Log("msg", "Failed to log out from Uyuni API", "err", err) + } + }() + + targetsForSystems, err := d.getTargetsForSystems(rpcClient, token, d.entitlement) + if err != nil { + return nil, err + } + + return []*targetgroup.Group{{Targets: targetsForSystems, Source: d.apiURL.String()}}, nil +} diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 4e2552110..6b451b595 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -288,6 +288,10 @@ serverset_sd_configs: triton_sd_configs: [ - ... ] +# List of Uyuni service discovery configurations. +uyuni_sd_configs: + [ - ... ] + # List of labeled statically configured targets for this job. static_configs: [ - ... ] @@ -2256,6 +2260,79 @@ tls_config: [ ] ``` +### `` + +Uyuni SD configurations allow retrieving scrape targets from managed systems +via [Uyuni](https://www.uyuni-project.org/) API. + +The following meta labels are available on targets during [relabeling](#relabel_config): + +* `__meta_uyuni_endpoint_name`: the name of the application endpoint +* `__meta_uyuni_exporter`: the exporter exposing metrics for the target +* `__meta_uyuni_groups`: the system groups of the target +* `__meta_uyuni_metrics_path`: metrics path for the target +* `__meta_uyuni_minion_hostname`: hostname of the Uyuni client +* `__meta_uyuni_primary_fqdn`: primary FQDN of the Uyuni client +* `__meta_uyuni_proxy_module`: the module name if _Exporter Exporter_ proxy is + configured for the target +* `__meta_uyuni_scheme`: the protocol scheme used for requests +* `__meta_uyuni_system_id`: the system ID of the client + +See below for the configuration options for Uyuni discovery: + +```yaml +# The URL to connect to the Uyuni server. +server: + +# Credentials are used to authenticate the requests to Uyuni API. +username: +password: + +# The entitlement string to filter eligible systems. +[ entitlement: | default = monitoring_entitled ] + +# The string by which Uyuni group names are joined into the groups label. +[ separator: | default = , ] + +# Refresh interval to re-read the managed targets list. +[ refresh_interval: | default = 60s ] + +# Optional HTTP basic authentication information, currently not supported by Uyuni. +basic_auth: + [ username: ] + [ password: ] + [ password_file: ] + +# Optional `Authorization` header configuration, currently not supported by Uyuni. +authorization: + # Sets the authentication type. + [ type: | default: Bearer ] + # Sets the credentials. It is mutually exclusive with + # `credentials_file`. + [ credentials: ] + # Sets the credentials to the credentials read from the configured file. + # It is mutually exclusive with `credentials`. + [ credentials_file: ] + +# Optional OAuth 2.0 configuration, currently not supported by Uyuni. +# Cannot be used at the same time as basic_auth or authorization. +oauth2: + [ ] + +# Optional proxy URL. + [ proxy_url: ] + +# Configure whether HTTP requests follow HTTP 3xx redirects. + [ follow_redirects: | default = true ] + +# TLS configuration. +tls_config: + [ ] +``` + +See [the Prometheus uyuni-sd configuration file](/documentation/examples/prometheus-uyuni.yml) +for a practical example on how to set up Uyuni Prometheus configuration. + ### `` A `static_config` allows specifying a list of targets and a common label set @@ -2518,6 +2595,10 @@ serverset_sd_configs: triton_sd_configs: [ - ... ] +# List of Uyuni service discovery configurations. +uyuni_sd_configs: + [ - ... ] + # List of labeled statically configured Alertmanagers. static_configs: [ - ... ] diff --git a/documentation/examples/prometheus-uyuni.yml b/documentation/examples/prometheus-uyuni.yml new file mode 100644 index 000000000..dd0d76916 --- /dev/null +++ b/documentation/examples/prometheus-uyuni.yml @@ -0,0 +1,36 @@ +# A example scrape configuration for running Prometheus with Uyuni. + +scrape_configs: + + # Make Prometheus scrape itself for metrics. + - job_name: 'prometheus' + static_configs: + - targets: ['localhost:9090'] + + # Discover Uyuni managed targets to scrape. + - job_name: 'uyuni' + + # Scrape Uyuni itself to discover new services. + uyuni_sd_configs: + - server: http://uyuni-project.org + username: gopher + password: hole + relabel_configs: + - source_labels: [__meta_uyuni_exporter] + target_label: exporter + - source_labels: [__meta_uyuni_groups] + target_label: groups + - source_labels: [__meta_uyuni_minion_hostname] + target_label: hostname + - source_labels: [__meta_uyuni_primary_fqdn] + regex: (.+) + target_label: hostname + - source_labels: [hostname, __address__] + regex: (.*);.*:(.*) + replacement: ${1}:${2} + target_label: __address__ + - source_labels: [__meta_uyuni_metrics_path] + regex: (.+) + target_label: __metrics_path__ + - source_labels: [__meta_uyuni_proxy_module] + target_label: __param_module diff --git a/go.mod b/go.mod index f03bcb8a5..3fb313c39 100644 --- a/go.mod +++ b/go.mod @@ -33,6 +33,7 @@ require ( github.com/hetznercloud/hcloud-go v1.32.0 github.com/influxdata/influxdb v1.9.3 github.com/json-iterator/go v1.1.11 + github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b github.com/linode/linodego v0.32.0 github.com/miekg/dns v1.1.43 github.com/moby/term v0.0.0-20201216013528-df9cb8a40635 // indirect diff --git a/go.sum b/go.sum index b1fc5326c..742048ffc 100644 --- a/go.sum +++ b/go.sum @@ -886,6 +886,8 @@ github.com/klauspost/compress v1.11.13/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdY github.com/klauspost/cpuid v0.0.0-20170728055534-ae7887de9fa5/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/crc32 v0.0.0-20161016154125-cb6bfca970f6/go.mod h1:+ZoRqAPRLkC4NPOvfYeR5KNOrY6TD+/sAC3HXPZgDYg= github.com/klauspost/pgzip v1.0.2-0.20170402124221-0bf5dcad4ada/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= +github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b h1:iNjcivnc6lhbvJA3LD622NPrUponluJrBWPIwGG/3Bg= +github.com/kolo/xmlrpc v0.0.0-20201022064351-38db28db192b/go.mod h1:pcaDhQK0/NJZEvtCO0qQPPropqV0sJOJ6YW7X+9kRwM= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.2/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=