Skip to content

Commit ad25130

Browse files
authored
Add support for metrics endpoint scrape jobs (#1862)
This PR adds resources to manage "metrics endpoint scrape jobs" . Metrics Endpoint enables customers to configure Grafana Cloud managed agents to scrape their publicly addressable metrics endpoint
1 parent 63fbba4 commit ad25130

File tree

24 files changed

+1706
-29
lines changed

24 files changed

+1706
-29
lines changed

.github/CODEOWNERS

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
/internal/resources/grafana/*_alerting_* @grafana/platform-monitoring @grafana/alerting-squad
44
/internal/resources/cloud/* @grafana/platform-monitoring @grafana/grafana-com-maintainers
5+
/internal/resources/connections/* @grafana/terraform-provider @grafana/middleware-apps
56
/internal/resources/machinelearning/* @grafana/platform-monitoring @grafana/machine-learning
67
/internal/resources/oncall/* @grafana/platform-monitoring @grafana/grafana-oncall
78
/internal/resources/slo/* @grafana/platform-monitoring @grafana/slo-squad
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "grafana_connections_metrics_endpoint_scrape_job Data Source - terraform-provider-grafana"
4+
subcategory: "Connections"
5+
description: |-
6+
7+
---
8+
9+
# grafana_connections_metrics_endpoint_scrape_job (Data Source)
10+
11+
12+
13+
## Example Usage
14+
15+
```terraform
16+
data "grafana_connections_metrics_endpoint_scrape_job" "ds_test" {
17+
stack_id = "1"
18+
name = "my-scrape-job"
19+
}
20+
```
21+
22+
<!-- schema generated by tfplugindocs -->
23+
## Schema
24+
25+
### Required
26+
27+
- `name` (String) The name of the Metrics Endpoint Scrape Job. Part of the Terraform Resource ID.
28+
- `stack_id` (String) The Stack ID of the Grafana Cloud instance. Part of the Terraform Resource ID.
29+
30+
### Read-Only
31+
32+
- `authentication_basic_password` (String, Sensitive) Password for basic authentication.
33+
- `authentication_basic_username` (String) Username for basic authentication.
34+
- `authentication_bearer_token` (String, Sensitive) Token for authentication bearer.
35+
- `authentication_method` (String) Method to pass authentication credentials: basic or bearer.
36+
- `enabled` (Boolean) Whether the metrics endpoint scrape job is enabled or not.
37+
- `id` (String) The Terraform Resource ID. This has the format "{{ stack_id }}:{{ name }}".
38+
- `scrape_interval_seconds` (Number) Frequency for scraping the metrics endpoint: 30, 60, or 120 seconds.
39+
- `url` (String) The url to scrape metrics.

docs/index.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,8 @@ resource "grafana_oncall_escalation" "example_notify_step" {
210210
- `ca_cert` (String) Certificate CA bundle (file path or literal value) to use to verify the Grafana server's certificate. May alternatively be set via the `GRAFANA_CA_CERT` environment variable.
211211
- `cloud_access_policy_token` (String, Sensitive) Access Policy Token for Grafana Cloud. May alternatively be set via the `GRAFANA_CLOUD_ACCESS_POLICY_TOKEN` environment variable.
212212
- `cloud_api_url` (String) Grafana Cloud's API URL. May alternatively be set via the `GRAFANA_CLOUD_API_URL` environment variable.
213+
- `connections_api_access_token` (String, Sensitive) A Grafana Connections API access token. May alternatively be set via the `GRAFANA_CONNECTIONS_API_ACCESS_TOKEN` environment variable.
214+
- `connections_api_url` (String) A Grafana Connections API address. May alternatively be set via the `GRAFANA_CONNECTIONS_API_URL` environment variable.
213215
- `http_headers` (Map of String, Sensitive) Optional. HTTP headers mapping keys to values used for accessing the Grafana and Grafana Cloud APIs. May alternatively be set via the `GRAFANA_HTTP_HEADERS` environment variable in JSON format.
214216
- `insecure_skip_verify` (Boolean) Skip TLS certificate verification. May alternatively be set via the `GRAFANA_INSECURE_SKIP_VERIFY` environment variable.
215217
- `oncall_access_token` (String, Sensitive) A Grafana OnCall access token. May alternatively be set via the `GRAFANA_ONCALL_ACCESS_TOKEN` environment variable.
@@ -224,6 +226,57 @@ resource "grafana_oncall_escalation" "example_notify_step" {
224226
- `tls_key` (String) Client TLS key (file path or literal value) to use to authenticate to the Grafana server. May alternatively be set via the `GRAFANA_TLS_KEY` environment variable.
225227
- `url` (String) The root URL of a Grafana server. May alternatively be set via the `GRAFANA_URL` environment variable.
226228

229+
### Managing Connections
230+
231+
#### Obtaining Connections access token
232+
233+
Before using the Terraform Provider to manage Grafana Connections resources, such as metrics endpoint scrape jobs, you need to create an access policy token on the Grafana Cloud Portal. This token is used to authenticate the provider to the Grafana Connections API.
234+
[These docs](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/authorize-services/#create-an-access-policy-for-a-stack) will guide you on how to create
235+
an access policy. The required permissions, or scopes, are `integration-management:read`, `integration-management:write` and `stacks:read`.
236+
237+
Also, by default the Access Policies UI will not show those scopes, instead, search for it using the `Add Scope` textbox, as shown in the following image:
238+
239+
<img src="https://grafana.com/media/docs/grafana-cloud/connections/connections-terraform-access-policy-create.png" width="700"/>
240+
241+
1. Use the `Add Scope` textbox (1) to search for the permissions you need to add to the access policy.
242+
1. Make sure that you configure the three required scopes. Once done, you'll see the selected scopes as in (2).
243+
244+
Having created an Access Policy, you can now create a token that will be used to authenticate the provider to the Connections API. You can do so just after creating the access policy, following
245+
the in-screen instructions, of following [this guide](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/authorize-services/#create-one-or-more-access-policy-tokens).
246+
247+
#### Obtaining Connections API hostname
248+
249+
Having created the token, we can find the correct Connections API hostname by running the following script, that requires `curl` and [`jq`](https://jqlang.github.io/jq/) installed:
250+
251+
```bash
252+
curl -sH "Authorization: Bearer <Access Token from previous step>" "https://grafana.com/api/instances" | \
253+
jq '[.items[]|{stackName: .slug, clusterName:.clusterSlug, connectionsAPIURL: "https://connections-api-\(.clusterSlug).grafana.net"}]'
254+
```
255+
256+
This script will return a list of all the Grafana Cloud stacks you own, with the Connections API hostname for each one. Choose the correct hostname for the stack you want to manage.
257+
For example, in the following response, the correct hostname for the `examplestackname` stack is `https://connections-api-prod-eu-west-0.grafana.net`.
258+
259+
```json
260+
[
261+
{
262+
"stackName": "examplestackname",
263+
"clusterName": "prod-eu-west-0",
264+
"connectionsAPIURL": "https://connections-api-prod-eu-west-0.grafana.net"
265+
}
266+
]
267+
```
268+
269+
#### Configuring Provider
270+
271+
Once you have the token and Connections API hostname, you can configure the provider as follows:
272+
273+
```hcl
274+
provider "grafana" {
275+
connections_api_url = "<Connections API URL from previous step>"
276+
connections_api_access_token = "<Access Token from previous step>"
277+
}
278+
```
279+
227280
## Authentication
228281

229282
One, or many, of the following authentication settings must be set. Each authentication setting allows a subset of resources to be used
@@ -246,3 +299,8 @@ You can use the `grafana_synthetic_monitoring_installation` resource as shown ab
246299

247300
[Grafana OnCall](https://grafana.com/docs/oncall/latest/oncall-api-reference/)
248301
uses API keys to allow access to the API. You can request a new OnCall API key in OnCall -> Settings page.
302+
303+
### `connections_api_access_token`
304+
An access policy token created on the [Grafana Cloud Portal](https://grafana.com/docs/grafana-cloud/account-management/authentication-and-permissions/access-policies/authorize-services/) to manage
305+
connections resources, such as Metrics Endpoint jobs.
306+
For guidance on creating one, see section [obtaining connections access token](#obtaining-connections-access-token)
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
---
2+
# generated by https://github.com/hashicorp/terraform-plugin-docs
3+
page_title: "grafana_connections_metrics_endpoint_scrape_job Resource - terraform-provider-grafana"
4+
subcategory: "Connections"
5+
description: |-
6+
7+
---
8+
9+
# grafana_connections_metrics_endpoint_scrape_job (Resource)
10+
11+
12+
13+
## Example Usage
14+
15+
```terraform
16+
resource "grafana_connections_metrics_endpoint_scrape_job" "test" {
17+
stack_id = "1"
18+
name = "my-scrape-job"
19+
enabled = true
20+
authentication_method = "basic"
21+
authentication_basic_username = "my-username"
22+
authentication_basic_password = "my-password"
23+
url = "https://grafana.com/metrics"
24+
scrape_interval_seconds = 120
25+
}
26+
```
27+
28+
<!-- schema generated by tfplugindocs -->
29+
## Schema
30+
31+
### Required
32+
33+
- `authentication_method` (String) Method to pass authentication credentials: basic or bearer.
34+
- `name` (String) The name of the metrics endpoint scrape job. Part of the Terraform Resource ID.
35+
- `stack_id` (String) The Stack ID of the Grafana Cloud instance. Part of the Terraform Resource ID.
36+
- `url` (String) The url to scrape metrics from; a valid HTTPs URL is required.
37+
38+
### Optional
39+
40+
- `authentication_basic_password` (String, Sensitive) Password for basic authentication, use if scrape job is using basic authentication method
41+
- `authentication_basic_username` (String) Username for basic authentication, use if scrape job is using basic authentication method
42+
- `authentication_bearer_token` (String, Sensitive) Bearer token used for authentication, use if scrape job is using bearer authentication method
43+
- `enabled` (Boolean) Whether the metrics endpoint scrape job is enabled or not.
44+
- `scrape_interval_seconds` (Number) Frequency for scraping the metrics endpoint: 30, 60, or 120 seconds.
45+
46+
### Read-Only
47+
48+
- `id` (String) The Terraform Resource ID. This has the format "{{ stack_id }}:{{ name }}".
49+
50+
## Import
51+
52+
Import is supported using the following syntax:
53+
54+
```shell
55+
terraform import grafana_connections_metrics_endpoint_scrape_job.name "{{ stack_id }}:{{ name }}"
56+
```
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
data "grafana_connections_metrics_endpoint_scrape_job" "ds_test" {
2+
stack_id = "1"
3+
name = "my-scrape-job"
4+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import grafana_connections_metrics_endpoint_scrape_job.name "{{ stack_id }}:{{ name }}"
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
terraform import grafana_connections_metrics_endpoint_scrape_job.name "{{ stack_id }}:{{ name }}"
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
resource "grafana_connections_metrics_endpoint_scrape_job" "test" {
2+
stack_id = "1"
3+
name = "my-scrape-job"
4+
enabled = true
5+
authentication_method = "basic"
6+
authentication_basic_username = "my-username"
7+
authentication_basic_password = "my-password"
8+
url = "https://grafana.com/metrics"
9+
scrape_interval_seconds = 120
10+
}

internal/common/client.go

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import (
1414
SMAPI "github.com/grafana/synthetic-monitoring-api-go-client"
1515
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
1616
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
17+
18+
"github.com/grafana/terraform-provider-grafana/v3/internal/common/connectionsapi"
1719
)
1820

1921
type Client struct {
@@ -22,11 +24,12 @@ type Client struct {
2224
GrafanaAPI *goapi.GrafanaHTTPAPI
2325
GrafanaAPIConfig *goapi.TransportConfig
2426

25-
GrafanaCloudAPI *gcom.APIClient
26-
SMAPI *SMAPI.Client
27-
MLAPI *mlapi.Client
28-
OnCallClient *onCallAPI.Client
29-
SLOClient *slo.APIClient
27+
GrafanaCloudAPI *gcom.APIClient
28+
SMAPI *SMAPI.Client
29+
MLAPI *mlapi.Client
30+
OnCallClient *onCallAPI.Client
31+
SLOClient *slo.APIClient
32+
ConnectionsAPIClient *connectionsapi.Client
3033

3134
alertingMutex sync.Mutex
3235
}
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
package connectionsapi
2+
3+
import (
4+
"bytes"
5+
"context"
6+
"encoding/json"
7+
"fmt"
8+
"io"
9+
"net/http"
10+
"net/url"
11+
"time"
12+
13+
"github.com/hashicorp/go-retryablehttp"
14+
)
15+
16+
type Client struct {
17+
authToken string
18+
apiURL url.URL
19+
client *http.Client
20+
userAgent string
21+
defaultHeaders map[string]string
22+
}
23+
24+
const (
25+
defaultRetries = 3
26+
defaultTimeout = 90 * time.Second
27+
pathPrefix = "/api/v1/stacks"
28+
)
29+
30+
func NewClient(authToken string, rawURL string, client *http.Client, userAgent string, defaultHeaders map[string]string) (*Client, error) {
31+
parsedURL, err := url.Parse(rawURL)
32+
33+
if err != nil {
34+
return nil, fmt.Errorf("failed to parse connections API url: %w", err)
35+
}
36+
37+
if client == nil {
38+
retryClient := retryablehttp.NewClient()
39+
retryClient.RetryMax = defaultRetries
40+
client = retryClient.StandardClient()
41+
client.Timeout = defaultTimeout
42+
}
43+
44+
return &Client{
45+
authToken: authToken,
46+
apiURL: *parsedURL,
47+
client: client,
48+
userAgent: userAgent,
49+
defaultHeaders: defaultHeaders,
50+
}, nil
51+
}
52+
53+
type apiResponseWrapper[T any] struct {
54+
Data T `json:"data"`
55+
}
56+
57+
type MetricsEndpointScrapeJob struct {
58+
Enabled bool `json:"enabled"`
59+
AuthenticationMethod string `json:"authentication_method"`
60+
AuthenticationBearerToken string `json:"bearer_token,omitempty"`
61+
AuthenticationBasicUsername string `json:"basic_username,omitempty"`
62+
AuthenticationBasicPassword string `json:"basic_password,omitempty"`
63+
URL string `json:"url"`
64+
ScrapeIntervalSeconds int64 `json:"scrape_interval_seconds"`
65+
}
66+
67+
func (c *Client) CreateMetricsEndpointScrapeJob(ctx context.Context, stackID, jobName string, jobData MetricsEndpointScrapeJob) (MetricsEndpointScrapeJob, error) {
68+
path := fmt.Sprintf("%s/%s/metrics-endpoint/jobs/%s", pathPrefix, stackID, jobName)
69+
respData := apiResponseWrapper[MetricsEndpointScrapeJob]{}
70+
err := c.doAPIRequest(ctx, http.MethodPost, path, &jobData, &respData)
71+
if err != nil {
72+
return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to create metrics endpoint scrape job %q: %w", jobName, err)
73+
}
74+
return respData.Data, nil
75+
}
76+
77+
func (c *Client) GetMetricsEndpointScrapeJob(ctx context.Context, stackID, jobName string) (MetricsEndpointScrapeJob, error) {
78+
path := fmt.Sprintf("%s/%s/metrics-endpoint/jobs/%s", pathPrefix, stackID, jobName)
79+
respData := apiResponseWrapper[MetricsEndpointScrapeJob]{}
80+
err := c.doAPIRequest(ctx, http.MethodGet, path, nil, &respData)
81+
if err != nil {
82+
return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to get metrics endpoint scrape job %q: %w", jobName, err)
83+
}
84+
return respData.Data, nil
85+
}
86+
87+
func (c *Client) UpdateMetricsEndpointScrapeJob(ctx context.Context, stackID, jobName string, jobData MetricsEndpointScrapeJob) (MetricsEndpointScrapeJob, error) {
88+
path := fmt.Sprintf("%s/%s/metrics-endpoint/jobs/%s", pathPrefix, stackID, jobName)
89+
respData := apiResponseWrapper[MetricsEndpointScrapeJob]{}
90+
err := c.doAPIRequest(ctx, http.MethodPut, path, &jobData, &respData)
91+
if err != nil {
92+
return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to update metrics endpoint scrape job %q: %w", jobName, err)
93+
}
94+
return respData.Data, nil
95+
}
96+
97+
func (c *Client) DeleteMetricsEndpointScrapeJob(ctx context.Context, stackID, jobName string) error {
98+
path := fmt.Sprintf("%s/%s/metrics-endpoint/jobs/%s", pathPrefix, stackID, jobName)
99+
err := c.doAPIRequest(ctx, http.MethodDelete, path, nil, nil)
100+
if err != nil {
101+
return fmt.Errorf("failed to delete metrics endpoint scrape job %q: %w", jobName, err)
102+
}
103+
return nil
104+
}
105+
106+
var (
107+
ErrNotFound = fmt.Errorf("not found")
108+
ErrUnauthorized = fmt.Errorf("request not authorized for stack")
109+
)
110+
111+
func (c *Client) doAPIRequest(ctx context.Context, method string, path string, body any, responseData any) error {
112+
var reqBodyBytes io.Reader
113+
if body != nil {
114+
bs, err := json.Marshal(body)
115+
if err != nil {
116+
return fmt.Errorf("failed to marshal request body: %w", err)
117+
}
118+
reqBodyBytes = bytes.NewReader(bs)
119+
}
120+
121+
req, err := http.NewRequestWithContext(ctx, method, c.apiURL.String()+path, reqBodyBytes)
122+
if err != nil {
123+
return fmt.Errorf("failed to create request: %w", err)
124+
}
125+
126+
for k, v := range c.defaultHeaders {
127+
req.Header.Add(k, v)
128+
}
129+
130+
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.authToken))
131+
req.Header.Add("Content-Type", "application/json")
132+
req.Header.Add("User-Agent", c.userAgent)
133+
134+
resp, err := c.client.Do(req)
135+
if err != nil {
136+
return fmt.Errorf("failed to do request: %w", err)
137+
}
138+
139+
bodyContents, err := io.ReadAll(resp.Body)
140+
resp.Body.Close()
141+
if err != nil {
142+
return fmt.Errorf("failed to read response body: %w", err)
143+
}
144+
if !(resp.StatusCode >= 200 && resp.StatusCode <= 299) {
145+
if resp.StatusCode == 404 {
146+
return ErrNotFound
147+
}
148+
if resp.StatusCode == 401 {
149+
return ErrUnauthorized
150+
}
151+
return fmt.Errorf("status: %d", resp.StatusCode)
152+
}
153+
if responseData != nil && resp.StatusCode != http.StatusNoContent {
154+
err = json.Unmarshal(bodyContents, &responseData)
155+
if err != nil {
156+
return fmt.Errorf("failed to unmarshal response body: %w", err)
157+
}
158+
}
159+
return nil
160+
}

0 commit comments

Comments
 (0)