From 20ff771593abc554b921a1a23c346a475ce56080 Mon Sep 17 00:00:00 2001 From: Vladimir Skesov Date: Thu, 12 Mar 2026 18:01:58 +0200 Subject: [PATCH 1/2] discovery: add DigitalOcean Managed Databases service discovery This adds 'databases' role to digitalocean_sd_config to discover DigitalOcean Managed Database clusters. It follows the multi-role design pattern by introducing a 'role' parameter (default: 'droplets'). Includes: - Support for Managed Databases API. - Pagination handling for Databases API. - Comprehensive meta labels for database targets. - Updated documentation and tests. Signed-off-by: Vladimir Skesov --- config/config_test.go | 1 + discovery/digitalocean/digitalocean.go | 143 ++++++++++++------ discovery/digitalocean/digitalocean_db.go | 124 +++++++++++++++ .../digitalocean/digitalocean_db_test.go | 141 +++++++++++++++++ discovery/digitalocean/digitalocean_test.go | 9 +- discovery/digitalocean/mock_test.go | 65 ++++++++ docs/configuration/configuration.md | 39 ++++- 7 files changed, 465 insertions(+), 57 deletions(-) create mode 100644 discovery/digitalocean/digitalocean_db.go create mode 100644 discovery/digitalocean/digitalocean_db_test.go diff --git a/config/config_test.go b/config/config_test.go index 1bae23d9d4..63038e2940 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -1151,6 +1151,7 @@ var expectedConf = &Config{ }, Port: 80, RefreshInterval: model.Duration(60 * time.Second), + Role: "droplets", }, }, }, diff --git a/discovery/digitalocean/digitalocean.go b/discovery/digitalocean/digitalocean.go index 0a185c2915..f80eb29901 100644 --- a/discovery/digitalocean/digitalocean.go +++ b/discovery/digitalocean/digitalocean.go @@ -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{} diff --git a/discovery/digitalocean/digitalocean_db.go b/discovery/digitalocean/digitalocean_db.go new file mode 100644 index 0000000000..9be5b65fb1 --- /dev/null +++ b/discovery/digitalocean/digitalocean_db.go @@ -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 +} diff --git a/discovery/digitalocean/digitalocean_db_test.go b/discovery/digitalocean/digitalocean_db_test.go new file mode 100644 index 0000000000..c385ad2cc0 --- /dev/null +++ b/discovery/digitalocean/digitalocean_db_test.go @@ -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) +} diff --git a/discovery/digitalocean/digitalocean_test.go b/discovery/digitalocean/digitalocean_test.go index 560d8d533a..733c863498 100644 --- a/discovery/digitalocean/digitalocean_test.go +++ b/discovery/digitalocean/digitalocean_test.go @@ -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) diff --git a/discovery/digitalocean/mock_test.go b/discovery/digitalocean/mock_test.go index d5703d7702..607cd06343 100644 --- a/discovery/digitalocean/mock_test.go +++ b/discovery/digitalocean/mock_test.go @@ -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": []}`) + } + }) +} diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 3682348e67..6142a63553 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -1501,10 +1501,15 @@ metadata and a single tag). ### `` 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_`: 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: | default = droplets ] + # The port to scrape metrics from. [ port: | default = 80 ] -# The time after which the droplets are refreshed. +# The time after which the targets are refreshed. [ refresh_interval: | default = 60s ] # HTTP client settings, including authentication methods (such as basic auth and @@ -1536,6 +1563,8 @@ The following meta labels are available on targets during [relabeling](#relabel_ ### `` +### `` + Docker SD configurations allow retrieving scrape targets from [Docker Engine](https://docs.docker.com/engine/) hosts. This SD discovers "containers" and will create a target for each network IP and port the container is configured to expose. From cf09d9b88503d2894a4e361a9732e9e4ab3394d6 Mon Sep 17 00:00:00 2001 From: Skesov <12987308+Skesov@users.noreply.github.com> Date: Mon, 30 Mar 2026 12:30:14 +0300 Subject: [PATCH 2/2] Update docs/configuration/configuration.md Co-authored-by: Ben Kochie Signed-off-by: Skesov <12987308+Skesov@users.noreply.github.com> --- docs/configuration/configuration.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/docs/configuration/configuration.md b/docs/configuration/configuration.md index 6142a63553..8457b7598d 100644 --- a/docs/configuration/configuration.md +++ b/docs/configuration/configuration.md @@ -1563,8 +1563,6 @@ The following meta labels are available on targets during [relabeling](#relabel_ ### `` -### `` - Docker SD configurations allow retrieving scrape targets from [Docker Engine](https://docs.docker.com/engine/) hosts. This SD discovers "containers" and will create a target for each network IP and port the container is configured to expose.