Skip to content

Commit 09a35a0

Browse files
authored
Merge pull request #848 from sapcc/designate_liquid_prometheus_query
switch liquid designate to use prometheus queries for usage
2 parents 68a5044 + e3ecefc commit 09a35a0

File tree

4 files changed

+79
-60
lines changed

4 files changed

+79
-60
lines changed

docs/liquids/designate.md

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@ This liquid provides support for the DNS service Designate.
1313

1414
## Service-specific configuration
1515

16-
None.
16+
| Field | Type | Description |
17+
| ----- | ---- | ----------- |
18+
| `prometheus_config.api` | [`promquery.Config`](https://pkg.go.dev/github.com/sapcc/go-bits/promquery#Config) | Configuration for the Prometheus connection from which usage data is queried by the liquid. |
19+
| `prometheus_config.queries.zones` | [`text/template`](https://pkg.go.dev/text/template) compatible string | Prometheus query for scraping the number of zones per project. The template should contain a filter string `{{.ProjectUUID}}` to be filled with the UUID of the project to be queried for usages. |
20+
| `prometheus_config.queries.recordsets_per_zone` | [`text/template`](https://pkg.go.dev/text/template) compatible string | Prometheus query for scraping the maximum number of recordsets across all zones of this project. The template should contain a filter string `{{.ProjectUUID}}` to be filled with the UUID of the project to be queried for usages. |
1721

1822
## Resources
1923

@@ -25,3 +29,9 @@ None.
2529
When the `recordsets_per_zone` quota is set, the backend quota for records per zone is set to 20 times that value, to
2630
fit into the `records_per_recordset` quota (which is set to 20 by default in Designate). The quota for records per zone
2731
cannot be managed explicitly in this liquid.
32+
33+
### Considerations for cloud operators
34+
35+
Because querying usage for the zones and especially recordsets resources is very inefficient using the Designate API, this liquid will instead collect usage data from Prometheus metrics.
36+
Your Designate operator will have to provide suitable metrics that report the count of all zones per project, as well as the number of recordsets in those zones.
37+
Be aware that when exporting these figures from the designate database, you have to take into account that deleted zones are soft deleted at first and have to be filtered from the result (`status != "DELETED"`).

internal/liquids/designate/client.go

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,6 @@ import (
88

99
"github.com/gophercloud/gophercloud/v2"
1010
"github.com/gophercloud/gophercloud/v2/openstack"
11-
"github.com/gophercloud/gophercloud/v2/openstack/dns/v2/zones"
12-
"github.com/gophercloud/gophercloud/v2/pagination"
1311
)
1412

1513
// Client is a gophercloud.ServiceClient for the Designate API.
@@ -49,46 +47,3 @@ func (c *Client) setQuota(ctx context.Context, projectUUID string, qs quotaSet)
4947
_, _, err := gophercloud.ParseResponse(c.Patch(ctx, url, qs, nil, &opts)) //nolint:bodyclose
5048
return err
5149
}
52-
53-
func (c *Client) listZoneIDs(ctx context.Context, projectUUID string) ([]string, error) {
54-
pager := zones.List(c.ServiceClient, zones.ListOpts{})
55-
pager.Headers = map[string]string{
56-
"X-Auth-All-Projects": "false",
57-
"X-Auth-Sudo-Project-Id": projectUUID,
58-
}
59-
60-
var ids []string
61-
err := pager.EachPage(ctx, func(ctx context.Context, page pagination.Page) (bool, error) {
62-
zones, err := zones.ExtractZones(page)
63-
if err != nil {
64-
return false, err
65-
}
66-
for _, zone := range zones {
67-
ids = append(ids, zone.ID)
68-
}
69-
return true, nil
70-
})
71-
return ids, err
72-
}
73-
74-
func (c *Client) countZoneRecordsets(ctx context.Context, projectUUID, zoneID string) (uint64, error) {
75-
url := c.ServiceURL("zones", zoneID, "recordsets")
76-
url += "?limit=1" // do not need all data about all recordsets, just the total count
77-
opts := gophercloud.RequestOpts{
78-
MoreHeaders: map[string]string{
79-
"X-Auth-All-Projects": "false",
80-
"X-Auth-Sudo-Project-Id": projectUUID,
81-
},
82-
}
83-
84-
var r gophercloud.Result
85-
_, r.Header, r.Err = gophercloud.ParseResponse(c.Get(ctx, url, &r.Body, &opts)) //nolint:bodyclose
86-
87-
var data struct {
88-
Metadata struct {
89-
Count uint64 `json:"total_count"`
90-
} `json:"metadata"`
91-
}
92-
err := r.ExtractInto(&data)
93-
return data.Metadata.Count, err
94-
}

internal/liquids/designate/liquid.go

Lines changed: 67 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,28 +4,67 @@
44
package designate
55

66
import (
7+
"bytes"
78
"context"
89
"errors"
10+
"fmt"
11+
"math"
912
"net/http"
13+
"text/template"
1014

1115
"github.com/gophercloud/gophercloud/v2"
1216
. "github.com/majewsky/gg/option"
1317
"github.com/sapcc/go-api-declarations/liquid"
18+
"github.com/sapcc/go-bits/promquery"
1419
"github.com/sapcc/go-bits/respondwith"
1520
)
1621

1722
// Logic implements the liquidapi.Logic interface for Designate.
1823
type Logic struct {
24+
// configuration
25+
PrometheusConfig struct {
26+
APIConfig promquery.Config `json:"api"`
27+
Queries struct {
28+
Zones string `json:"zones"`
29+
RecordsetsPerZone string `json:"recordsets_per_zone"`
30+
} `json:"queries"`
31+
} `json:"prometheus_config"`
1932
// connections
2033
DesignateV2 *Client `json:"-"`
34+
Templates struct {
35+
Zones *template.Template `json:"-"`
36+
RecordsetsPerZone *template.Template `json:"-"`
37+
}
2138
}
2239

2340
// Init implements the liquidapi.Logic interface.
2441
func (l *Logic) Init(ctx context.Context, provider *gophercloud.ProviderClient, eo gophercloud.EndpointOpts) (err error) {
2542
l.DesignateV2, err = newClient(provider, eo)
43+
if err != nil {
44+
return fmt.Errorf("init designate v2 client: %w", err)
45+
}
46+
l.Templates.Zones, err = parseQuery(l.PrometheusConfig.Queries.Zones)
47+
if err != nil {
48+
return fmt.Errorf("parse zones query: %w", err)
49+
}
50+
l.Templates.RecordsetsPerZone, err = parseQuery(l.PrometheusConfig.Queries.RecordsetsPerZone)
51+
if err != nil {
52+
return fmt.Errorf("parse recordsets per zone query: %w", err)
53+
}
2654
return err
2755
}
2856

57+
func parseQuery(query string) (tmpl *template.Template, err error) {
58+
if query == "" {
59+
return tmpl, errors.New("query is empty")
60+
}
61+
tmpl, err = template.New("query").Parse(query)
62+
if err != nil {
63+
return tmpl, fmt.Errorf("error while parsing the template: %w", err)
64+
}
65+
return tmpl, nil
66+
}
67+
2968
// BuildServiceInfo implements the liquidapi.Logic interface.
3069
func (l *Logic) BuildServiceInfo(ctx context.Context) (liquid.ServiceInfo, error) {
3170
return liquid.ServiceInfo{
@@ -62,24 +101,38 @@ func (l *Logic) ScanUsage(ctx context.Context, projectUUID string, req liquid.Se
62101
return liquid.ServiceUsageReport{}, err
63102
}
64103

65-
// to query usage, start by listing all zones
66-
zoneIDs, err := l.DesignateV2.listZoneIDs(ctx, projectUUID)
104+
// note: The following data is available via designate API, but we transitioned to use
105+
// Prometheus queries because of the faster response times for more frequent scraping.
106+
client, err := l.PrometheusConfig.APIConfig.Connect()
67107
if err != nil {
68-
return liquid.ServiceUsageReport{}, err
108+
return liquid.ServiceUsageReport{}, fmt.Errorf("while getting prometheus client: %w", err)
69109
}
70-
71-
// query "recordsets per zone" usage by counting recordsets in each zone
72-
// individually (we could count all recordsets over the all project at once,
73-
// but that won't help since the quota applies per individual zone)
74-
maxRecordsetsPerZone := uint64(0)
75-
for _, zoneID := range zoneIDs {
76-
count, err := l.DesignateV2.countZoneRecordsets(ctx, projectUUID, zoneID)
110+
scrapeUsageMetric := func(template *template.Template) (uint64, error) {
111+
data := map[string]any{
112+
"ProjectUUID": projectUUID,
113+
}
114+
var templated bytes.Buffer
115+
err = template.Execute(&templated, data)
116+
if err != nil {
117+
return 0, fmt.Errorf("error while filling the template: %w", err)
118+
}
119+
value, err := client.GetSingleValue(ctx, templated.String(), new(0.0))
77120
if err != nil {
78-
return liquid.ServiceUsageReport{}, err
121+
return 0, fmt.Errorf("error while retrieving prometheus value: %w", err)
79122
}
80-
if maxRecordsetsPerZone < count {
81-
maxRecordsetsPerZone = count
123+
if value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
124+
return 0, fmt.Errorf("unexpected value: %f", value)
82125
}
126+
return uint64(math.Round(value)), nil
127+
}
128+
129+
zones, err := scrapeUsageMetric(l.Templates.Zones)
130+
if err != nil {
131+
return liquid.ServiceUsageReport{}, fmt.Errorf("while scraping zones usage: %w", err)
132+
}
133+
maxRecordsetsPerZone, err := scrapeUsageMetric(l.Templates.RecordsetsPerZone)
134+
if err != nil {
135+
return liquid.ServiceUsageReport{}, fmt.Errorf("while scraping recordsets per zone usage: %w", err)
83136
}
84137

85138
return liquid.ServiceUsageReport{
@@ -88,7 +141,7 @@ func (l *Logic) ScanUsage(ctx context.Context, projectUUID string, req liquid.Se
88141
"zones": {
89142
Quota: Some(quotas.Zones),
90143
PerAZ: liquid.InAnyAZ(liquid.AZResourceUsageReport{
91-
Usage: uint64(len(zoneIDs)),
144+
Usage: zones,
92145
}),
93146
},
94147
"recordsets_per_zone": {

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ func main() {
8888
opts.ServiceInfoRefreshInterval = 0
8989
must.Succeed(liquidapi.Run(ctx, &cronus.Logic{}, opts))
9090
case "designate":
91+
opts.TakesConfiguration = true
9192
opts.ServiceInfoRefreshInterval = 0
9293
must.Succeed(liquidapi.Run(ctx, &designate.Logic{}, opts))
9394
case "ironic":

0 commit comments

Comments
 (0)