Merge pull request #18287 from Skesov/feat/digitalocean-db-sd

discovery: add DigitalOcean Managed Databases service discovery
This commit is contained in:
Julien 2026-03-30 16:47:59 +02:00 committed by GitHub
commit ced39f8a74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 463 additions and 57 deletions

View File

@ -1151,6 +1151,7 @@ var expectedConf = &Config{
},
Port: 80,
RefreshInterval: model.Duration(60 * time.Second),
Role: "droplets",
},
},
},

View File

@ -34,29 +34,58 @@ import (
"github.com/prometheus/prometheus/discovery/targetgroup"
)
// metaLabelPrefix is the meta prefix used for all meta labels.
const (
doLabel = model.MetaLabelPrefix + "digitalocean_"
doLabelID = doLabel + "droplet_id"
doLabelName = doLabel + "droplet_name"
doLabelImage = doLabel + "image"
doLabelImageName = doLabel + "image_name"
doLabelPrivateIPv4 = doLabel + "private_ipv4"
doLabelPublicIPv4 = doLabel + "public_ipv4"
doLabelPublicIPv6 = doLabel + "public_ipv6"
doLabelRegion = doLabel + "region"
doLabelSize = doLabel + "size"
doLabelStatus = doLabel + "status"
doLabelFeatures = doLabel + "features"
doLabelTags = doLabel + "tags"
doLabelVPC = doLabel + "vpc"
separator = ","
metaLabelPrefix = model.MetaLabelPrefix + "digitalocean_"
separator = ","
)
const (
doLabelID = metaLabelPrefix + "droplet_id"
doLabelName = metaLabelPrefix + "droplet_name"
doLabelImage = metaLabelPrefix + "image"
doLabelImageName = metaLabelPrefix + "image_name"
doLabelPrivateIPv4 = metaLabelPrefix + "private_ipv4"
doLabelPublicIPv4 = metaLabelPrefix + "public_ipv4"
doLabelPublicIPv6 = metaLabelPrefix + "public_ipv6"
doLabelRegion = metaLabelPrefix + "region"
doLabelSize = metaLabelPrefix + "size"
doLabelStatus = metaLabelPrefix + "status"
doLabelFeatures = metaLabelPrefix + "features"
doLabelTags = metaLabelPrefix + "tags"
doLabelVPC = metaLabelPrefix + "vpc"
)
// Role is the role of the target within the DigitalOcean ecosystem.
type Role string
const (
// DropletsRole discovers targets from DigitalOcean Droplets.
DropletsRole Role = "droplets"
// DatabasesRole discovers targets from DigitalOcean Managed Databases.
DatabasesRole Role = "databases"
)
// UnmarshalYAML implements the yaml.Unmarshaler interface.
func (c *Role) UnmarshalYAML(unmarshal func(any) error) error {
if err := unmarshal((*string)(c)); err != nil {
return err
}
switch *c {
case DropletsRole, DatabasesRole:
return nil
default:
return fmt.Errorf("unknown DigitalOcean SD role %q", *c)
}
}
// DefaultSDConfig is the default DigitalOcean SD configuration.
var DefaultSDConfig = SDConfig{
Port: 80,
RefreshInterval: model.Duration(60 * time.Second),
HTTPClientConfig: config.DefaultHTTPClientConfig,
Role: DropletsRole,
}
func init() {
@ -76,6 +105,10 @@ type SDConfig struct {
RefreshInterval model.Duration `yaml:"refresh_interval"`
Port int `yaml:"port"`
Role Role `yaml:"role"`
// Internal field for testing.
HTTPClient *http.Client
}
// Name returns the name of the Config.
@ -99,58 +132,78 @@ func (c *SDConfig) UnmarshalYAML(unmarshal func(any) error) error {
if err != nil {
return err
}
if c.Role == "" {
return errors.New("role missing (one of: droplets, databases)")
}
return c.HTTPClientConfig.Validate()
}
// Discovery periodically performs DigitalOcean requests. It implements
// the Discoverer interface.
type Discovery struct {
*refresh.Discovery
client *godo.Client
port int
}
// NewDiscovery returns a new Discovery which periodically refreshes its targets.
func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (*Discovery, error) {
func NewDiscovery(conf *SDConfig, opts discovery.DiscovererOptions) (discovery.Discoverer, error) {
m, ok := opts.Metrics.(*digitaloceanMetrics)
if !ok {
return nil, errors.New("invalid discovery metrics type")
}
d := &Discovery{
port: conf.Port,
}
rt, err := config.NewRoundTripperFromConfig(conf.HTTPClientConfig, "digitalocean_sd")
r, err := newRefresher(conf)
if err != nil {
return nil, err
}
d.client, err = godo.New(
&http.Client{
return refresh.NewDiscovery(
refresh.Options{
Logger: opts.Logger,
Mech: "digitalocean",
SetName: opts.SetName,
Interval: time.Duration(conf.RefreshInterval),
RefreshF: r.refresh,
MetricsInstantiator: m.refreshMetrics,
},
), nil
}
type refresher interface {
refresh(context.Context) ([]*targetgroup.Group, error)
}
func newRefresher(conf *SDConfig) (refresher, error) {
httpClient := conf.HTTPClient
if httpClient == nil {
rt, err := config.NewRoundTripperFromConfig(conf.HTTPClientConfig, "digitalocean_sd")
if err != nil {
return nil, err
}
httpClient = &http.Client{
Transport: rt,
Timeout: time.Duration(conf.RefreshInterval),
},
}
}
client, err := godo.New(
httpClient,
godo.SetUserAgent(version.PrometheusUserAgent()),
)
if err != nil {
return nil, fmt.Errorf("error setting up digital ocean agent: %w", err)
}
d.Discovery = refresh.NewDiscovery(
refresh.Options{
Logger: opts.Logger,
Mech: "digitalocean",
SetName: opts.SetName,
Interval: time.Duration(conf.RefreshInterval),
RefreshF: d.refresh,
MetricsInstantiator: m.refreshMetrics,
},
)
return d, nil
switch conf.Role {
case DropletsRole:
return &dropletsDiscovery{client: client, port: conf.Port}, nil
case DatabasesRole:
return &databasesDiscovery{client: client, port: conf.Port}, nil
}
return nil, fmt.Errorf("unknown DigitalOcean SD role %q", conf.Role)
}
func (d *Discovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) {
type dropletsDiscovery struct {
client *godo.Client
port int
}
func (d *dropletsDiscovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) {
tg := &targetgroup.Group{
Source: "DigitalOcean",
}
@ -213,7 +266,7 @@ func (d *Discovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) {
return []*targetgroup.Group{tg}, nil
}
func (d *Discovery) listDroplets(ctx context.Context) ([]godo.Droplet, error) {
func (d *dropletsDiscovery) listDroplets(ctx context.Context) ([]godo.Droplet, error) {
var (
droplets []godo.Droplet
opts = &godo.ListOptions{}

View File

@ -0,0 +1,124 @@
// Copyright 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 digitalocean
import (
"context"
"fmt"
"net"
"strconv"
"github.com/digitalocean/godo"
"github.com/prometheus/common/model"
"github.com/prometheus/prometheus/discovery/targetgroup"
)
const (
dbLabelID = metaLabelPrefix + "db_id"
dbLabelName = metaLabelPrefix + "db_name"
dbLabelEngine = metaLabelPrefix + "db_engine"
dbLabelVersion = metaLabelPrefix + "db_version"
dbLabelStatus = metaLabelPrefix + "db_status"
dbLabelRegion = metaLabelPrefix + "db_region"
dbLabelSize = metaLabelPrefix + "db_size"
dbLabelNumNodes = metaLabelPrefix + "db_num_nodes"
dbLabelHost = metaLabelPrefix + "db_host"
dbLabelPrivateHost = metaLabelPrefix + "db_private_host"
dbLabelTagPrefix = metaLabelPrefix + "db_tag_"
)
type databasesDiscovery struct {
client *godo.Client
port int
}
func (d *databasesDiscovery) refresh(ctx context.Context) ([]*targetgroup.Group, error) {
tg := &targetgroup.Group{
Source: "DigitalOcean Databases",
}
clusters, err := d.listClusters(ctx)
if err != nil {
return nil, err
}
for _, cluster := range clusters {
labels := model.LabelSet{
dbLabelID: model.LabelValue(cluster.ID),
dbLabelName: model.LabelValue(cluster.Name),
dbLabelEngine: model.LabelValue(cluster.EngineSlug),
dbLabelVersion: model.LabelValue(cluster.VersionSlug),
dbLabelStatus: model.LabelValue(cluster.Status),
dbLabelRegion: model.LabelValue(cluster.RegionSlug),
dbLabelSize: model.LabelValue(cluster.SizeSlug),
dbLabelNumNodes: model.LabelValue(strconv.Itoa(cluster.NumNodes)),
}
host := ""
if cluster.PrivateConnection != nil {
host = cluster.PrivateConnection.Host
labels[dbLabelPrivateHost] = model.LabelValue(host)
}
if cluster.Connection != nil {
labels[dbLabelHost] = model.LabelValue(cluster.Connection.Host)
if host == "" {
host = cluster.Connection.Host
}
}
if host != "" {
addr := net.JoinHostPort(host, strconv.FormatUint(uint64(d.port), 10))
labels[model.AddressLabel] = model.LabelValue(addr)
}
for _, tag := range cluster.Tags {
labels[dbLabelTagPrefix+model.LabelName(tag)] = "true"
}
tg.Targets = append(tg.Targets, labels)
}
return []*targetgroup.Group{tg}, nil
}
func (d *databasesDiscovery) listClusters(ctx context.Context) ([]godo.Database, error) {
var (
clusters []godo.Database
opts = &godo.ListOptions{
Page: 1,
PerPage: 100,
}
)
for {
paginatedClusters, resp, err := d.client.Databases.List(ctx, opts)
if err != nil {
return nil, fmt.Errorf("error while listing database clusters page %d: %w", opts.Page, err)
}
if len(paginatedClusters) == 0 {
break
}
clusters = append(clusters, paginatedClusters...)
if resp.Links != nil && !resp.Links.IsLastPage() {
page, err := resp.Links.CurrentPage()
if err == nil {
opts.Page = page + 1
continue
}
}
opts.Page++
}
return clusters, nil
}

View File

@ -0,0 +1,141 @@
// Copyright 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 digitalocean
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/require"
)
func TestDigitalOceanDBRefresh(t *testing.T) {
mock := NewSDMock(t)
mock.Setup()
defer mock.ShutdownServer()
mock.HandleDatabasesList()
cfg := DefaultSDConfig
cfg.Role = DatabasesRole
cfg.Port = 9273
cfg.HTTPClient = mock.Server.Client()
d, err := newRefresher(&cfg)
require.NoError(t, err)
// Inject the mock URL into the godo client since we can't easily change it via HTTPClient.
endpoint, _ := url.Parse(mock.Endpoint())
d.(*databasesDiscovery).client.BaseURL = endpoint
tgs, err := d.refresh(context.Background())
require.NoError(t, err)
require.Len(t, tgs, 1)
tg := tgs[0]
require.NotNil(t, tg)
require.Len(t, tg.Targets, 2)
expectedTargets := []struct {
labels model.LabelSet
}{
{
labels: model.LabelSet{
"__address__": "do-fra1-pg-trading-001-do-user-XXXXX-0.f.db.ondigitalocean.com:9273",
"__meta_digitalocean_db_id": "9cc10173-e9ea-4176-9dbc-a4cee4c4ff30",
"__meta_digitalocean_db_name": "do-fra1-pg-trading-001",
"__meta_digitalocean_db_engine": "pg",
"__meta_digitalocean_db_version": "16",
"__meta_digitalocean_db_status": "online",
"__meta_digitalocean_db_region": "fra1",
"__meta_digitalocean_db_size": "db-s-1vcpu-2gb",
"__meta_digitalocean_db_num_nodes": "1",
"__meta_digitalocean_db_host": "do-fra1-pg-trading-001-do-user-XXXXX-0.f.db.ondigitalocean.com",
"__meta_digitalocean_db_tag_prod": "true",
"__meta_digitalocean_db_tag_market": "true",
},
},
{
labels: model.LabelSet{
"__address__": "private-db-host:9273",
"__meta_digitalocean_db_id": "a0b1c2d3-e4f5-4677-8899-001122334455",
"__meta_digitalocean_db_name": "private-db",
"__meta_digitalocean_db_engine": "mysql",
"__meta_digitalocean_db_version": "8",
"__meta_digitalocean_db_status": "online",
"__meta_digitalocean_db_region": "nyc1",
"__meta_digitalocean_db_size": "db-s-2vcpu-4gb",
"__meta_digitalocean_db_num_nodes": "2",
"__meta_digitalocean_db_host": "public-db-host",
"__meta_digitalocean_db_private_host": "private-db-host",
},
},
}
for i, expected := range expectedTargets {
require.Equal(t, expected.labels, tg.Targets[i])
}
}
func TestDigitalOceanDBRefreshPagination(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
switch page {
case "1", "":
fmt.Fprint(w, `
{
"databases": [
{"id": "db-1", "name": "db-1", "engine": "pg", "version": "16", "status": "online", "region": "fra1", "size": "db-s-1vcpu-2gb", "num_nodes": 1}
],
"links": {
"pages": {
"next": "http://example.com/v2/databases?page=2"
}
}
}
`)
case "2":
fmt.Fprint(w, `
{
"databases": [
{"id": "db-2", "name": "db-2", "engine": "pg", "version": "16", "status": "online", "region": "fra1", "size": "db-s-1vcpu-2gb", "num_nodes": 1}
]
}
`)
default:
fmt.Fprint(w, `{"databases": []}`)
}
}))
defer ts.Close()
cfg := DefaultSDConfig
cfg.Role = DatabasesRole
cfg.HTTPClient = ts.Client()
d, err := newRefresher(&cfg)
require.NoError(t, err)
endpoint, _ := url.Parse(ts.URL)
d.(*databasesDiscovery).client.BaseURL = endpoint
tgs, err := d.refresh(context.Background())
require.NoError(t, err)
require.Len(t, tgs, 1)
require.Len(t, tgs[0].Targets, 2)
}

View File

@ -21,7 +21,6 @@ import (
"github.com/prometheus/client_golang/prometheus"
"github.com/prometheus/common/model"
"github.com/prometheus/common/promslog"
"github.com/stretchr/testify/require"
"github.com/prometheus/prometheus/discovery"
@ -57,15 +56,11 @@ func TestDigitalOceanSDRefresh(t *testing.T) {
defer metrics.Unregister()
defer refreshMetrics.Unregister()
d, err := NewDiscovery(&cfg, discovery.DiscovererOptions{
Logger: promslog.NewNopLogger(),
Metrics: metrics,
SetName: "digitalocean",
})
d, err := newRefresher(&cfg)
require.NoError(t, err)
endpoint, err := url.Parse(sdmock.Mock.Endpoint())
require.NoError(t, err)
d.client.BaseURL = endpoint
d.(*dropletsDiscovery).client.BaseURL = endpoint
ctx := context.Background()
tgs, err := d.refresh(ctx)

View File

@ -645,3 +645,68 @@ func (m *SDMock) HandleDropletsList() {
)
})
}
// HandleDatabasesList mocks database clusters list.
func (m *SDMock) HandleDatabasesList() {
m.Mux.HandleFunc("/v2/databases", func(w http.ResponseWriter, r *http.Request) {
page := r.URL.Query().Get("page")
switch page {
case "1", "":
fmt.Fprint(w, `
{
"databases": [
{
"id": "9cc10173-e9ea-4176-9dbc-a4cee4c4ff30",
"name": "do-fra1-pg-trading-001",
"engine": "pg",
"version": "16",
"status": "online",
"region": "fra1",
"size": "db-s-1vcpu-2gb",
"num_nodes": 1,
"connection": {
"host": "do-fra1-pg-trading-001-do-user-XXXXX-0.f.db.ondigitalocean.com",
"port": 25060,
"user": "doadmin",
"password": "XXX",
"database": "defaultdb",
"ssl": true
},
"tags": ["prod", "market"]
}
],
"links": {
"pages": {
"next": "http://example.com/v2/databases?page=2"
}
}
}
`)
case "2":
fmt.Fprint(w, `
{
"databases": [
{
"id": "a0b1c2d3-e4f5-4677-8899-001122334455",
"name": "private-db",
"engine": "mysql",
"version": "8",
"status": "online",
"region": "nyc1",
"size": "db-s-2vcpu-4gb",
"num_nodes": 2,
"connection": {
"host": "public-db-host"
},
"private_connection": {
"host": "private-db-host"
}
}
]
}
`)
default:
fmt.Fprint(w, `{"databases": []}`)
}
})
}

View File

@ -1501,10 +1501,15 @@ metadata and a single tag).
### `<digitalocean_sd_config>`
DigitalOcean SD configurations allow retrieving scrape targets from [DigitalOcean's](https://www.digitalocean.com/)
Droplets API.
This service discovery uses the public IPv4 address by default, by that can be
changed with relabeling, as demonstrated in [the Prometheus digitalocean-sd
configuration file](/documentation/examples/prometheus-digitalocean.yml).
API.
This service discovery supports multiple roles through the `role` parameter.
One of the following `role` types can be configured to discover targets:
#### `droplets`
The `droplets` role discovers targets from DigitalOcean Droplets. The public IPv4 address is used by default,
but may be changed with relabeling.
The following meta labels are available on targets during [relabeling](#relabel_config):
@ -1522,11 +1527,33 @@ The following meta labels are available on targets during [relabeling](#relabel_
* `__meta_digitalocean_tags`: the comma-separated list of tags of the droplet
* `__meta_digitalocean_vpc`: the id of the droplet's VPC
#### `databases`
The `databases` role discovers targets from DigitalOcean Managed Databases.
The following meta labels are available on targets during [relabeling](#relabel_config):
* `__meta_digitalocean_db_id`: the id of the database cluster
* `__meta_digitalocean_db_name`: the name of the database cluster
* `__meta_digitalocean_db_engine`: the engine of the database cluster (e.g., `pg`, `mysql`, `redis`, `mongodb`)
* `__meta_digitalocean_db_version`: the version of the engine
* `__meta_digitalocean_db_status`: the status of the database cluster
* `__meta_digitalocean_db_region`: the region of the database cluster
* `__meta_digitalocean_db_size`: the size of the database cluster
* `__meta_digitalocean_db_num_nodes`: the number of nodes in the database cluster
* `__meta_digitalocean_db_host`: the public host of the database cluster
* `__meta_digitalocean_db_private_host`: the private host of the database cluster
* `__meta_digitalocean_db_tag_<tagname>`: each tag of the database cluster, with its value set to `true`
```yaml
# The DigitalOcean role to use for service discovery.
# Must be one of: droplets or databases.
[ role: <string> | default = droplets ]
# The port to scrape metrics from.
[ port: <int> | default = 80 ]
# The time after which the droplets are refreshed.
# The time after which the targets are refreshed.
[ refresh_interval: <duration> | default = 60s ]
# HTTP client settings, including authentication methods (such as basic auth and