You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
prometheus/discovery/puppetdb/puppetdb.go

269 lines
7.7 KiB

// 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 puppetdb
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/url"
"path"
"strconv"
"strings"
"time"
"github.com/go-kit/log"
"github.com/grafana/regexp"
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/config"
"github.com/prometheus/common/model"
"github.com/prometheus/common/version"
"github.com/prometheus/prometheus/discovery"
"github.com/prometheus/prometheus/discovery/refresh"
"github.com/prometheus/prometheus/discovery/targetgroup"
)
const (
pdbLabel = model.MetaLabelPrefix + "puppetdb_"
pdbLabelQuery = pdbLabel + "query"
pdbLabelCertname = pdbLabel + "certname"
pdbLabelResource = pdbLabel + "resource"
pdbLabelType = pdbLabel + "type"
pdbLabelTitle = pdbLabel + "title"
pdbLabelExported = pdbLabel + "exported"
pdbLabelTags = pdbLabel + "tags"
pdbLabelFile = pdbLabel + "file"
pdbLabelEnvironment = pdbLabel + "environment"
pdbLabelParameter = pdbLabel + "parameter_"
separator = ","
)
var (
// DefaultSDConfig is the default PuppetDB SD configuration.
DefaultSDConfig = SDConfig{
RefreshInterval: model.Duration(60 * time.Second),
Port: 80,
HTTPClientConfig: config.DefaultHTTPClientConfig,
}
matchContentType = regexp.MustCompile(`^(?i:application\/json(;\s*charset=("utf-8"|utf-8))?)$`)
userAgent = fmt.Sprintf("Prometheus/%s", version.Version)
)
func init() {
discovery.RegisterConfig(&SDConfig{})
}
// SDConfig is the configuration for PuppetDB based discovery.
type SDConfig struct {
HTTPClientConfig config.HTTPClientConfig `yaml:",inline"`
RefreshInterval model.Duration `yaml:"refresh_interval,omitempty"`
URL string `yaml:"url"`
Query string `yaml:"query"`
IncludeParameters bool `yaml:"include_parameters"`
Port int `yaml:"port"`
}
// NewDiscovererMetrics implements discovery.Config.
func (*SDConfig) NewDiscovererMetrics(reg prometheus.Registerer, rmi discovery.RefreshMetricsInstantiator) discovery.DiscovererMetrics {
return &puppetdbMetrics{
refreshMetrics: rmi,
}
}
// Name returns the name of the Config.
func (*SDConfig) Name() string { return "puppetdb" }
// NewDiscoverer returns a Discoverer for the Config.
func (c *SDConfig) NewDiscoverer(opts discovery.DiscovererOptions) (discovery.Discoverer, error) {
return NewDiscovery(c, opts.Logger, opts.Metrics)
}
// SetDirectory joins any relative file paths with dir.
func (c *SDConfig) SetDirectory(dir string) {
c.HTTPClientConfig.SetDirectory(dir)
}
// 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.URL == "" {
return fmt.Errorf("URL is missing")
}
parsedURL, err := url.Parse(c.URL)
if err != nil {
return err
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return fmt.Errorf("URL scheme must be 'http' or 'https'")
}
if parsedURL.Host == "" {
return fmt.Errorf("host is missing in URL")
}
if c.Query == "" {
return fmt.Errorf("query missing")
}
return c.HTTPClientConfig.Validate()
}
// Discovery provides service discovery functionality based
// on PuppetDB resources.
type Discovery struct {
*refresh.Discovery
url string
query string
port int
includeParameters bool
client *http.Client
}
// NewDiscovery returns a new PuppetDB discovery for the given config.
func NewDiscovery(conf *SDConfig, logger log.Logger, metrics discovery.DiscovererMetrics) (*Discovery, error) {
m, ok := metrics.(*puppetdbMetrics)
if !ok {
return nil, fmt.Errorf("invalid discovery metrics type")
}
if logger == nil {
logger = log.NewNopLogger()
}
client, err := config.NewClientFromConfig(conf.HTTPClientConfig, "http")
if err != nil {
return nil, err
}
client.Timeout = time.Duration(conf.RefreshInterval)
u, err := url.Parse(conf.URL)
if err != nil {
return nil, err
}
u.Path = path.Join(u.Path, "pdb/query/v4")
d := &Discovery{
url: u.String(),
port: conf.Port,
query: conf.Query,
includeParameters: conf.IncludeParameters,
client: client,
}
d.Discovery = refresh.NewDiscovery(
refresh.Options{
Logger: logger,
Mech: "puppetdb",
Interval: time.Duration(conf.RefreshInterval),
RefreshF: d.refresh,
MetricsInstantiator: m.refreshMetrics,
},
)
return d, nil
}
func (d *Discovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) {
body := struct {
Query string `json:"query"`
}{d.query}
bodyBytes, err := json.Marshal(body)
if err != nil {
return nil, err
}
req, err := http.NewRequest(http.MethodPost, d.url, bytes.NewBuffer(bodyBytes))
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", userAgent)
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/json")
resp, err := d.client.Do(req.WithContext(ctx))
if err != nil {
return nil, err
}
defer func() {
io.Copy(io.Discard, resp.Body)
resp.Body.Close()
}()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned HTTP status %s", resp.Status)
}
if ct := resp.Header.Get("Content-Type"); !matchContentType.MatchString(ct) {
return nil, fmt.Errorf("unsupported content type %s", resp.Header.Get("Content-Type"))
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
var resources []Resource
if err := json.Unmarshal(b, &resources); err != nil {
return nil, err
}
tg := &targetgroup.Group{
// Use a pseudo-URL as source.
Source: d.url + "?query=" + d.query,
}
for _, resource := range resources {
labels := model.LabelSet{
pdbLabelQuery: model.LabelValue(d.query),
pdbLabelCertname: model.LabelValue(resource.Certname),
pdbLabelResource: model.LabelValue(resource.Resource),
pdbLabelType: model.LabelValue(resource.Type),
pdbLabelTitle: model.LabelValue(resource.Title),
pdbLabelExported: model.LabelValue(strconv.FormatBool(resource.Exported)),
pdbLabelFile: model.LabelValue(resource.File),
pdbLabelEnvironment: model.LabelValue(resource.Environment),
}
addr := net.JoinHostPort(resource.Certname, strconv.FormatUint(uint64(d.port), 10))
labels[model.AddressLabel] = model.LabelValue(addr)
if len(resource.Tags) > 0 {
// We surround the separated list with the separator as well. This way regular expressions
// in relabeling rules don't have to consider tag positions.
tags := separator + strings.Join(resource.Tags, separator) + separator
labels[pdbLabelTags] = model.LabelValue(tags)
}
// Parameters are not included by default. This should only be enabled
// on select resources as it might expose secrets on the Prometheus UI
// for certain resources.
if d.includeParameters {
for k, v := range resource.Parameters.toLabels() {
labels[pdbLabelParameter+k] = v
}
}
tg.Targets = append(tg.Targets, labels)
}
return []*targetgroup.Group{tg}, nil
}