Skip to content

Commit

Permalink
Add support for metrics endpoint scrape jobs (#1862)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
fridgepoet authored Nov 4, 2024
1 parent 63fbba4 commit ad25130
Show file tree
Hide file tree
Showing 24 changed files with 1,706 additions and 29 deletions.
1 change: 1 addition & 0 deletions .github/CODEOWNERS
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

/internal/resources/grafana/*_alerting_* @grafana/platform-monitoring @grafana/alerting-squad
/internal/resources/cloud/* @grafana/platform-monitoring @grafana/grafana-com-maintainers
/internal/resources/connections/* @grafana/terraform-provider @grafana/middleware-apps
/internal/resources/machinelearning/* @grafana/platform-monitoring @grafana/machine-learning
/internal/resources/oncall/* @grafana/platform-monitoring @grafana/grafana-oncall
/internal/resources/slo/* @grafana/platform-monitoring @grafana/slo-squad
Expand Down
39 changes: 39 additions & 0 deletions docs/data-sources/connections_metrics_endpoint_scrape_job.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "grafana_connections_metrics_endpoint_scrape_job Data Source - terraform-provider-grafana"
subcategory: "Connections"
description: |-
---

# grafana_connections_metrics_endpoint_scrape_job (Data Source)



## Example Usage

```terraform
data "grafana_connections_metrics_endpoint_scrape_job" "ds_test" {
stack_id = "1"
name = "my-scrape-job"
}
```

<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `name` (String) The name of the Metrics Endpoint Scrape Job. Part of the Terraform Resource ID.
- `stack_id` (String) The Stack ID of the Grafana Cloud instance. Part of the Terraform Resource ID.

### Read-Only

- `authentication_basic_password` (String, Sensitive) Password for basic authentication.
- `authentication_basic_username` (String) Username for basic authentication.
- `authentication_bearer_token` (String, Sensitive) Token for authentication bearer.
- `authentication_method` (String) Method to pass authentication credentials: basic or bearer.
- `enabled` (Boolean) Whether the metrics endpoint scrape job is enabled or not.
- `id` (String) The Terraform Resource ID. This has the format "{{ stack_id }}:{{ name }}".
- `scrape_interval_seconds` (Number) Frequency for scraping the metrics endpoint: 30, 60, or 120 seconds.
- `url` (String) The url to scrape metrics.
58 changes: 58 additions & 0 deletions docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ resource "grafana_oncall_escalation" "example_notify_step" {
- `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.
- `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.
- `cloud_api_url` (String) Grafana Cloud's API URL. May alternatively be set via the `GRAFANA_CLOUD_API_URL` environment variable.
- `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.
- `connections_api_url` (String) A Grafana Connections API address. May alternatively be set via the `GRAFANA_CONNECTIONS_API_URL` environment variable.
- `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.
- `insecure_skip_verify` (Boolean) Skip TLS certificate verification. May alternatively be set via the `GRAFANA_INSECURE_SKIP_VERIFY` environment variable.
- `oncall_access_token` (String, Sensitive) A Grafana OnCall access token. May alternatively be set via the `GRAFANA_ONCALL_ACCESS_TOKEN` environment variable.
Expand All @@ -224,6 +226,57 @@ resource "grafana_oncall_escalation" "example_notify_step" {
- `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.
- `url` (String) The root URL of a Grafana server. May alternatively be set via the `GRAFANA_URL` environment variable.

### Managing Connections

#### Obtaining Connections access token

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.
[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
an access policy. The required permissions, or scopes, are `integration-management:read`, `integration-management:write` and `stacks:read`.

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:

<img src="https://grafana.com/media/docs/grafana-cloud/connections/connections-terraform-access-policy-create.png" width="700"/>

1. Use the `Add Scope` textbox (1) to search for the permissions you need to add to the access policy.
1. Make sure that you configure the three required scopes. Once done, you'll see the selected scopes as in (2).

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
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).

#### Obtaining Connections API hostname

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:

```bash
curl -sH "Authorization: Bearer <Access Token from previous step>" "https://grafana.com/api/instances" | \
jq '[.items[]|{stackName: .slug, clusterName:.clusterSlug, connectionsAPIURL: "https://connections-api-\(.clusterSlug).grafana.net"}]'
```

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.
For example, in the following response, the correct hostname for the `examplestackname` stack is `https://connections-api-prod-eu-west-0.grafana.net`.

```json
[
{
"stackName": "examplestackname",
"clusterName": "prod-eu-west-0",
"connectionsAPIURL": "https://connections-api-prod-eu-west-0.grafana.net"
}
]
```

#### Configuring Provider

Once you have the token and Connections API hostname, you can configure the provider as follows:

```hcl
provider "grafana" {
connections_api_url = "<Connections API URL from previous step>"
connections_api_access_token = "<Access Token from previous step>"
}
```

## Authentication

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

[Grafana OnCall](https://grafana.com/docs/oncall/latest/oncall-api-reference/)
uses API keys to allow access to the API. You can request a new OnCall API key in OnCall -> Settings page.

### `connections_api_access_token`
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
connections resources, such as Metrics Endpoint jobs.
For guidance on creating one, see section [obtaining connections access token](#obtaining-connections-access-token)
56 changes: 56 additions & 0 deletions docs/resources/connections_metrics_endpoint_scrape_job.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "grafana_connections_metrics_endpoint_scrape_job Resource - terraform-provider-grafana"
subcategory: "Connections"
description: |-
---

# grafana_connections_metrics_endpoint_scrape_job (Resource)



## Example Usage

```terraform
resource "grafana_connections_metrics_endpoint_scrape_job" "test" {
stack_id = "1"
name = "my-scrape-job"
enabled = true
authentication_method = "basic"
authentication_basic_username = "my-username"
authentication_basic_password = "my-password"
url = "https://grafana.com/metrics"
scrape_interval_seconds = 120
}
```

<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `authentication_method` (String) Method to pass authentication credentials: basic or bearer.
- `name` (String) The name of the metrics endpoint scrape job. Part of the Terraform Resource ID.
- `stack_id` (String) The Stack ID of the Grafana Cloud instance. Part of the Terraform Resource ID.
- `url` (String) The url to scrape metrics from; a valid HTTPs URL is required.

### Optional

- `authentication_basic_password` (String, Sensitive) Password for basic authentication, use if scrape job is using basic authentication method
- `authentication_basic_username` (String) Username for basic authentication, use if scrape job is using basic authentication method
- `authentication_bearer_token` (String, Sensitive) Bearer token used for authentication, use if scrape job is using bearer authentication method
- `enabled` (Boolean) Whether the metrics endpoint scrape job is enabled or not.
- `scrape_interval_seconds` (Number) Frequency for scraping the metrics endpoint: 30, 60, or 120 seconds.

### Read-Only

- `id` (String) The Terraform Resource ID. This has the format "{{ stack_id }}:{{ name }}".

## Import

Import is supported using the following syntax:

```shell
terraform import grafana_connections_metrics_endpoint_scrape_job.name "{{ stack_id }}:{{ name }}"
```
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
data "grafana_connections_metrics_endpoint_scrape_job" "ds_test" {
stack_id = "1"
name = "my-scrape-job"
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
terraform import grafana_connections_metrics_endpoint_scrape_job.name "{{ stack_id }}:{{ name }}"
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
terraform import grafana_connections_metrics_endpoint_scrape_job.name "{{ stack_id }}:{{ name }}"
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
resource "grafana_connections_metrics_endpoint_scrape_job" "test" {
stack_id = "1"
name = "my-scrape-job"
enabled = true
authentication_method = "basic"
authentication_basic_username = "my-username"
authentication_basic_password = "my-password"
url = "https://grafana.com/metrics"
scrape_interval_seconds = 120
}
13 changes: 8 additions & 5 deletions internal/common/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
SMAPI "github.com/grafana/synthetic-monitoring-api-go-client"
"github.com/hashicorp/terraform-plugin-sdk/v2/diag"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"

"github.com/grafana/terraform-provider-grafana/v3/internal/common/connectionsapi"
)

type Client struct {
Expand All @@ -22,11 +24,12 @@ type Client struct {
GrafanaAPI *goapi.GrafanaHTTPAPI
GrafanaAPIConfig *goapi.TransportConfig

GrafanaCloudAPI *gcom.APIClient
SMAPI *SMAPI.Client
MLAPI *mlapi.Client
OnCallClient *onCallAPI.Client
SLOClient *slo.APIClient
GrafanaCloudAPI *gcom.APIClient
SMAPI *SMAPI.Client
MLAPI *mlapi.Client
OnCallClient *onCallAPI.Client
SLOClient *slo.APIClient
ConnectionsAPIClient *connectionsapi.Client

alertingMutex sync.Mutex
}
Expand Down
160 changes: 160 additions & 0 deletions internal/common/connectionsapi/client.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
package connectionsapi

import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"

"github.com/hashicorp/go-retryablehttp"
)

type Client struct {
authToken string
apiURL url.URL
client *http.Client
userAgent string
defaultHeaders map[string]string
}

const (
defaultRetries = 3
defaultTimeout = 90 * time.Second
pathPrefix = "/api/v1/stacks"
)

func NewClient(authToken string, rawURL string, client *http.Client, userAgent string, defaultHeaders map[string]string) (*Client, error) {
parsedURL, err := url.Parse(rawURL)

if err != nil {
return nil, fmt.Errorf("failed to parse connections API url: %w", err)
}

if client == nil {
retryClient := retryablehttp.NewClient()
retryClient.RetryMax = defaultRetries
client = retryClient.StandardClient()
client.Timeout = defaultTimeout
}

return &Client{
authToken: authToken,
apiURL: *parsedURL,
client: client,
userAgent: userAgent,
defaultHeaders: defaultHeaders,
}, nil
}

type apiResponseWrapper[T any] struct {
Data T `json:"data"`
}

type MetricsEndpointScrapeJob struct {
Enabled bool `json:"enabled"`
AuthenticationMethod string `json:"authentication_method"`
AuthenticationBearerToken string `json:"bearer_token,omitempty"`
AuthenticationBasicUsername string `json:"basic_username,omitempty"`
AuthenticationBasicPassword string `json:"basic_password,omitempty"`
URL string `json:"url"`
ScrapeIntervalSeconds int64 `json:"scrape_interval_seconds"`
}

func (c *Client) CreateMetricsEndpointScrapeJob(ctx context.Context, stackID, jobName string, jobData MetricsEndpointScrapeJob) (MetricsEndpointScrapeJob, error) {
path := fmt.Sprintf("%s/%s/metrics-endpoint/jobs/%s", pathPrefix, stackID, jobName)
respData := apiResponseWrapper[MetricsEndpointScrapeJob]{}
err := c.doAPIRequest(ctx, http.MethodPost, path, &jobData, &respData)
if err != nil {
return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to create metrics endpoint scrape job %q: %w", jobName, err)
}
return respData.Data, nil
}

func (c *Client) GetMetricsEndpointScrapeJob(ctx context.Context, stackID, jobName string) (MetricsEndpointScrapeJob, error) {
path := fmt.Sprintf("%s/%s/metrics-endpoint/jobs/%s", pathPrefix, stackID, jobName)
respData := apiResponseWrapper[MetricsEndpointScrapeJob]{}
err := c.doAPIRequest(ctx, http.MethodGet, path, nil, &respData)
if err != nil {
return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to get metrics endpoint scrape job %q: %w", jobName, err)
}
return respData.Data, nil
}

func (c *Client) UpdateMetricsEndpointScrapeJob(ctx context.Context, stackID, jobName string, jobData MetricsEndpointScrapeJob) (MetricsEndpointScrapeJob, error) {
path := fmt.Sprintf("%s/%s/metrics-endpoint/jobs/%s", pathPrefix, stackID, jobName)
respData := apiResponseWrapper[MetricsEndpointScrapeJob]{}
err := c.doAPIRequest(ctx, http.MethodPut, path, &jobData, &respData)
if err != nil {
return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to update metrics endpoint scrape job %q: %w", jobName, err)
}
return respData.Data, nil
}

func (c *Client) DeleteMetricsEndpointScrapeJob(ctx context.Context, stackID, jobName string) error {
path := fmt.Sprintf("%s/%s/metrics-endpoint/jobs/%s", pathPrefix, stackID, jobName)
err := c.doAPIRequest(ctx, http.MethodDelete, path, nil, nil)
if err != nil {
return fmt.Errorf("failed to delete metrics endpoint scrape job %q: %w", jobName, err)
}
return nil
}

var (
ErrNotFound = fmt.Errorf("not found")
ErrUnauthorized = fmt.Errorf("request not authorized for stack")
)

func (c *Client) doAPIRequest(ctx context.Context, method string, path string, body any, responseData any) error {
var reqBodyBytes io.Reader
if body != nil {
bs, err := json.Marshal(body)
if err != nil {
return fmt.Errorf("failed to marshal request body: %w", err)
}
reqBodyBytes = bytes.NewReader(bs)
}

req, err := http.NewRequestWithContext(ctx, method, c.apiURL.String()+path, reqBodyBytes)
if err != nil {
return fmt.Errorf("failed to create request: %w", err)
}

for k, v := range c.defaultHeaders {
req.Header.Add(k, v)
}

req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.authToken))
req.Header.Add("Content-Type", "application/json")
req.Header.Add("User-Agent", c.userAgent)

resp, err := c.client.Do(req)
if err != nil {
return fmt.Errorf("failed to do request: %w", err)
}

bodyContents, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("failed to read response body: %w", err)
}
if !(resp.StatusCode >= 200 && resp.StatusCode <= 299) {
if resp.StatusCode == 404 {
return ErrNotFound
}
if resp.StatusCode == 401 {
return ErrUnauthorized
}
return fmt.Errorf("status: %d", resp.StatusCode)
}
if responseData != nil && resp.StatusCode != http.StatusNoContent {
err = json.Unmarshal(bodyContents, &responseData)
if err != nil {
return fmt.Errorf("failed to unmarshal response body: %w", err)
}
}
return nil
}
Loading

0 comments on commit ad25130

Please sign in to comment.