diff --git a/internal/common/client.go b/internal/common/client.go index f8d0268ba..9d1a4dc33 100644 --- a/internal/common/client.go +++ b/internal/common/client.go @@ -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 { @@ -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 } diff --git a/internal/common/connectionsapi/client.go b/internal/common/connectionsapi/client.go new file mode 100644 index 000000000..4be7c80e7 --- /dev/null +++ b/internal/common/connectionsapi/client.go @@ -0,0 +1,149 @@ +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 +} + +const ( + defaultRetries = 3 + defaultTimeout = 90 * time.Second + pathPrefix = "/metrics-endpoint/stack" +) + +func NewClient(authToken string, rawURL string, client *http.Client) (*Client, error) { + parsedURL, err := url.Parse(rawURL) + if parsedURL.Scheme != "https" { + return nil, fmt.Errorf("https URL scheme expected, provided %q", parsedURL.Scheme) + } + + 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, + }, nil +} + +type apiResponseWrapper[T any] struct { + Data T `json:"data"` +} + +type MetricsEndpointScrapeJob struct { + Name string `json:"name"` + 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 string, jobData MetricsEndpointScrapeJob) (MetricsEndpointScrapeJob, error) { + path := fmt.Sprintf("%s/%s/jobs/%s", pathPrefix, stackID, jobData.Name) + respData := apiResponseWrapper[map[string]MetricsEndpointScrapeJob]{} + err := c.doAPIRequest(ctx, http.MethodPost, path, &jobData, &respData) + if err != nil { + return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to create metrics endpoint scrape job: %w", err) + } + return respData.Data[jobData.Name], nil +} + +func (c *Client) GetMetricsEndpointScrapeJob(ctx context.Context, stackID string, jobName string) (MetricsEndpointScrapeJob, error) { + path := fmt.Sprintf("%s/%s/jobs/%s", pathPrefix, stackID, jobName) + respData := apiResponseWrapper[map[string]MetricsEndpointScrapeJob]{} + err := c.doAPIRequest(ctx, http.MethodGet, path, nil, &respData) + if err != nil { + return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to get metrics endpoint scrape job: %w", err) + } + return respData.Data[jobName], nil +} + +func (c *Client) UpdateMetricsEndpointScrapeJob(ctx context.Context, stackID string, jobName string, jobData MetricsEndpointScrapeJob) (MetricsEndpointScrapeJob, error) { + path := fmt.Sprintf("%s/%s/jobs/%s", pathPrefix, stackID, jobName) + respData := apiResponseWrapper[map[string]MetricsEndpointScrapeJob]{} + err := c.doAPIRequest(ctx, http.MethodPut, path, &jobData, &respData) + if err != nil { + return MetricsEndpointScrapeJob{}, fmt.Errorf("failed to update metrics endpoint scrape job: %w", err) + } + return respData.Data[jobName], nil +} + +func (c *Client) DeleteMetricsEndpointScrapeJob(ctx context.Context, stackID string, jobName string) error { + path := fmt.Sprintf("%s/%s/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: %w", err) + } + return nil +} + +var ErrNotFound = fmt.Errorf("job not found") + +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) + } + var resp *http.Response + + req, err := http.NewRequestWithContext(ctx, method, c.apiURL.String()+path, reqBodyBytes) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.authToken)) + req.Header.Add("Content-Type", "application/json") + + 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 + } + return fmt.Errorf("status: %d, body: %v", resp.StatusCode, string(bodyContents)) + } + 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 +} diff --git a/internal/common/connectionsapi/client_test.go b/internal/common/connectionsapi/client_test.go new file mode 100644 index 000000000..08cfcbab5 --- /dev/null +++ b/internal/common/connectionsapi/client_test.go @@ -0,0 +1,312 @@ +package connectionsapi_test + +import ( + "context" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + + "github.com/grafana/terraform-provider-grafana/v3/internal/common/connectionsapi" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_sets_auth_token_and_content_type(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "Bearer some token", r.Header.Get("Authorization")) + assert.Equal(t, "application/json", r.Header.Get("Content-Type")) + _, _ = fmt.Fprintf(w, `{}`) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + _, err = c.CreateMetricsEndpointScrapeJob(context.Background(), "some stack id", connectionsapi.MetricsEndpointScrapeJob{}) + require.NoError(t, err) +} + +func TestClient_CreateMetricsEndpointScrapeJob(t *testing.T) { + t.Run("successfully sends request and receives response", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "/metrics-endpoint/stack/some-stack-id/jobs/test_job", r.URL.Path) + requestBody, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, ` + { + "name":"test_job", + "enabled":true, + "authentication_method":"basic", + "basic_password":"my-password", + "basic_username":"my-username", + "url":"https://my-example-url.com:9000/metrics", + "scrape_interval_seconds":120 + }`, string(requestBody)) + + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(` + { + "status":"success", + "data":{ + "test_job":{ + "enabled":true, + "authentication_method":"basic", + "basic_username":"my-username", + "basic_password":"my-password", + "url":"https://my-example-url.com:9000/metrics", + "scrape_interval_seconds":120, + "flavor":"default" + } + } + }`)) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + actualJob, err := c.CreateMetricsEndpointScrapeJob(context.Background(), "some-stack-id", connectionsapi.MetricsEndpointScrapeJob{ + Name: "test_job", + Enabled: true, + AuthenticationMethod: "basic", + AuthenticationBasicUsername: "my-username", + AuthenticationBasicPassword: "my-password", + URL: "https://my-example-url.com:9000/metrics", + ScrapeIntervalSeconds: 120, + }) + assert.NoError(t, err) + + assert.Equal(t, connectionsapi.MetricsEndpointScrapeJob{ + Enabled: true, + AuthenticationMethod: "basic", + AuthenticationBasicUsername: "my-username", + AuthenticationBasicPassword: "my-password", + URL: "https://my-example-url.com:9000/metrics", + ScrapeIntervalSeconds: 120, + }, actualJob) + }) + + t.Run("returns error when connections API responds 500", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"some error"}`)) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + _, err = c.CreateMetricsEndpointScrapeJob(context.Background(), "some-stack-id", connectionsapi.MetricsEndpointScrapeJob{}) + + assert.Error(t, err) + assert.Equal(t, `failed to create metrics endpoint scrape job: status: 500, body: {"some error"}`, err.Error()) + }) +} + +func TestClient_GetMetricsEndpointScrapeJob(t *testing.T) { + t.Run("successfully sends request and receives response", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodGet, r.Method) + assert.Equal(t, "/metrics-endpoint/stack/some-stack-id/jobs/test_job", r.URL.Path) + + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(` + { + "status":"success", + "data":{ + "test_job":{ + "enabled":true, + "authentication_method":"basic", + "basic_username":"my-username", + "basic_password":"my-password", + "url":"https://my-example-url.com:9000/metrics", + "scrape_interval_seconds":120, + "flavor":"default" + } + } + }`)) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + actualJob, err := c.GetMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "test_job") + assert.NoError(t, err) + + assert.Equal(t, connectionsapi.MetricsEndpointScrapeJob{ + Enabled: true, + AuthenticationMethod: "basic", + AuthenticationBasicUsername: "my-username", + AuthenticationBasicPassword: "my-password", + URL: "https://my-example-url.com:9000/metrics", + ScrapeIntervalSeconds: 120, + }, actualJob) + }) + + t.Run("returns ErrorNotFound when connections API responds 404", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + _, err = c.GetMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "job-name") + + assert.Error(t, err) + assert.ErrorIs(t, err, connectionsapi.ErrNotFound) + assert.Equal(t, `failed to get metrics endpoint scrape job: job not found`, err.Error()) + }) + + t.Run("returns error when connections API responds 500", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"some error"}`)) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + _, err = c.GetMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "job-name") + + assert.Error(t, err) + assert.Equal(t, `failed to get metrics endpoint scrape job: status: 500, body: {"some error"}`, err.Error()) + }) +} + +func TestClient_UpdateMetricsEndpointScrapeJob(t *testing.T) { + t.Run("successfully sends request and receives response", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPut, r.Method) + assert.Equal(t, "/metrics-endpoint/stack/some-stack-id/jobs/test_job", r.URL.Path) + requestBody, err := io.ReadAll(r.Body) + require.NoError(t, err) + assert.JSONEq(t, ` + { + "name":"test_job", + "enabled":true, + "authentication_method":"bearer", + "bearer_token":"some token", + "url":"https://updated-url.com:9000/metrics", + "scrape_interval_seconds":120 + }`, string(requestBody)) + + w.WriteHeader(http.StatusAccepted) + _, _ = w.Write([]byte(` + { + "status":"success", + "data":{ + "test_job":{ + "enabled":true, + "authentication_method":"bearer", + "bearer_token":"some token", + "url":"https://updated-url.com:9000/metrics", + "scrape_interval_seconds":120, + "flavor":"default" + } + } + }`)) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + actualJob, err := c.UpdateMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "test_job", + connectionsapi.MetricsEndpointScrapeJob{ + Name: "test_job", + Enabled: true, + AuthenticationMethod: "bearer", + AuthenticationBearerToken: "some token", + URL: "https://updated-url.com:9000/metrics", + ScrapeIntervalSeconds: 120, + }) + assert.NoError(t, err) + + assert.Equal(t, connectionsapi.MetricsEndpointScrapeJob{ + Enabled: true, + AuthenticationMethod: "bearer", + AuthenticationBearerToken: "some token", + URL: "https://updated-url.com:9000/metrics", + ScrapeIntervalSeconds: 120, + }, actualJob) + }) + + t.Run("returns ErrorNotFound when connections API responds 404", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + _, _ = w.Write([]byte(`{"some error"}`)) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + _, err = c.UpdateMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "job-name", connectionsapi.MetricsEndpointScrapeJob{}) + + assert.Error(t, err) + assert.ErrorIs(t, err, connectionsapi.ErrNotFound) + assert.Equal(t, `failed to update metrics endpoint scrape job: job not found`, err.Error()) + }) + + t.Run("returns error when connections API responds 500", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"some error"}`)) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + _, err = c.UpdateMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "job-name", connectionsapi.MetricsEndpointScrapeJob{}) + + assert.Error(t, err) + assert.Equal(t, `failed to update metrics endpoint scrape job: status: 500, body: {"some error"}`, err.Error()) + }) +} + +func TestClient_DeleteMetricsEndpointScrapeJob(t *testing.T) { + t.Run("successfully sends request and receives response", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "/metrics-endpoint/stack/some-stack-id/jobs/test_job", r.URL.Path) + + w.WriteHeader(http.StatusOK) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + err = c.DeleteMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "test_job") + + assert.NoError(t, err) + }) + + t.Run("returns ErrorNotFound when connections API responds 404", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(404) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + err = c.DeleteMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "job-name") + + assert.Error(t, err) + assert.ErrorIs(t, err, connectionsapi.ErrNotFound) + assert.Equal(t, `failed to delete metrics endpoint scrape job: job not found`, err.Error()) + }) + + t.Run("returns error when connections API responds 500", func(t *testing.T) { + svr := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(500) + _, _ = w.Write([]byte(`{"some error"}`)) + })) + defer svr.Close() + + c, err := connectionsapi.NewClient("some token", svr.URL, svr.Client()) + require.NoError(t, err) + err = c.DeleteMetricsEndpointScrapeJob(context.Background(), "some-stack-id", "job-name") + + assert.Error(t, err) + assert.Equal(t, `failed to delete metrics endpoint scrape job: status: 500, body: {"some error"}`, err.Error()) + }) +} diff --git a/internal/resources/connections/resource_metrics_endpoint_scrape_job.go b/internal/resources/connections/resource_metrics_endpoint_scrape_job.go index 427ff3f75..c82d51d45 100644 --- a/internal/resources/connections/resource_metrics_endpoint_scrape_job.go +++ b/internal/resources/connections/resource_metrics_endpoint_scrape_job.go @@ -2,9 +2,11 @@ package connections import ( "context" + "fmt" + "net/url" "github.com/grafana/terraform-provider-grafana/v3/internal/common" - + "github.com/grafana/terraform-provider-grafana/v3/internal/common/connectionsapi" "github.com/hashicorp/terraform-plugin-framework-validators/int64validator" "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" "github.com/hashicorp/terraform-plugin-framework/resource" @@ -22,6 +24,7 @@ var ( ) type resourceMetricsEndpointScrapeJob struct { + client *connectionsapi.Client } var Resources = makeResourceMetricsEndpointScrapeJob() @@ -35,16 +38,49 @@ func makeResourceMetricsEndpointScrapeJob() *common.Resource { ) } -func (r resourceMetricsEndpointScrapeJob) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - // TODO implement me - panic("implement me") +func (r *resourceMetricsEndpointScrapeJob) Configure(ctx context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + // Configure is called multiple times (sometimes when ProviderData is not yet available), we only want to configure once + if req.ProviderData == nil || r.client != nil { + return + } + + client, err := withClientForResource(req, resp) + if err != nil { + return + } + + r.client = client +} + +func withClientForResource(req resource.ConfigureRequest, resp *resource.ConfigureResponse) (*connectionsapi.Client, error) { + client, ok := req.ProviderData.(*common.Client) + + if !ok { + resp.Diagnostics.AddError( + "Unexpected Resource Configure Type", + fmt.Sprintf("Expected *common.Client, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return nil, fmt.Errorf("unexpected Resource Configure Type: %T, expected *common.Client", req.ProviderData) + } + + if client.ConnectionsAPIClient == nil { + resp.Diagnostics.AddError( + "The Grafana Provider is missing a configuration for the Metrics Endpoint API.", + "Please ensure that connections_url and connections_access_token are set in the provider configuration.", + ) + + return nil, fmt.Errorf("ConnectionsAPI is nil") + } + + return client.ConnectionsAPIClient, nil } -func (r resourceMetricsEndpointScrapeJob) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { +func (r *resourceMetricsEndpointScrapeJob) Metadata(ctx context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { resp.TypeName = resourceMetricsEndpointScrapeJobTerraformName } -func (r resourceMetricsEndpointScrapeJob) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { +func (r *resourceMetricsEndpointScrapeJob) Schema(ctx context.Context, req resource.SchemaRequest, resp *resource.SchemaResponse) { resp.Schema = schema.Schema{ Attributes: map[string]schema.Attribute{ "id": schema.StringAttribute{ @@ -99,8 +135,8 @@ func (r resourceMetricsEndpointScrapeJob) Schema(ctx context.Context, req resour }, "url": schema.StringAttribute{ Description: "The url to scrape metrics; a valid HTTPs URL is required.", - //Validators: []validator.String{}, // TODO: Find a validator for urls - Required: true, + Validators: []validator.String{HTTPSURLValidator{}}, + Required: true, }, "scrape_interval_seconds": schema.Int64Attribute{ Description: "Frequency for scraping the metrics endpoint: 30, 60, or 120 seconds.", @@ -113,22 +149,68 @@ func (r resourceMetricsEndpointScrapeJob) Schema(ctx context.Context, req resour } } -func (r resourceMetricsEndpointScrapeJob) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { +func (r *resourceMetricsEndpointScrapeJob) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { // TODO implement me panic("implement me") } -func (r resourceMetricsEndpointScrapeJob) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { +func (r *resourceMetricsEndpointScrapeJob) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { // TODO implement me panic("implement me") } -func (r resourceMetricsEndpointScrapeJob) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { +func (r *resourceMetricsEndpointScrapeJob) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { // TODO implement me panic("implement me") } -func (r resourceMetricsEndpointScrapeJob) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { +func (r *resourceMetricsEndpointScrapeJob) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { // TODO implement me panic("implement me") } + +type HTTPSURLValidator struct{} + +func (v HTTPSURLValidator) Description(ctx context.Context) string { + return v.MarkdownDescription(ctx) +} + +func (v HTTPSURLValidator) MarkdownDescription(_ context.Context) string { + return "value must be valid URL with HTTPS" +} + +func (v HTTPSURLValidator) ValidateString(ctx context.Context, request validator.StringRequest, response *validator.StringResponse) { + value := request.ConfigValue.ValueString() + + if value == "" { + response.Diagnostics.AddAttributeError( + request.Path, + v.Description(ctx), + "A valid URL is required.\n\n"+ + fmt.Sprintf("Given Value: %q\n", value), + ) + return + } + + u, err := url.Parse(value) + if err != nil { + response.Diagnostics.AddAttributeError( + request.Path, + v.Description(ctx), + "A string value was provided that is not a valid URL.\n\n"+ + "Given Value: "+value+"\n"+ + "Error: "+err.Error(), + ) + return + } + + if u.Scheme != "https" { + response.Diagnostics.AddAttributeError( + request.Path, + v.Description(ctx), + "A URL was provided, protocol must be HTTPS.\n\n"+ + fmt.Sprintf("Given Value: %q\n", value), + ) + return + } +} diff --git a/internal/resources/connections/resource_metrics_endpoint_scrape_job_test.go b/internal/resources/connections/resource_metrics_endpoint_scrape_job_test.go new file mode 100644 index 000000000..62293ff77 --- /dev/null +++ b/internal/resources/connections/resource_metrics_endpoint_scrape_job_test.go @@ -0,0 +1,89 @@ +package connections_test + +import ( + "context" + "testing" + + "github.com/grafana/terraform-provider-grafana/v3/internal/resources/connections" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" +) + +func Test_httpsURLValidator(t *testing.T) { + t.Parallel() + testCases := map[string]struct { + providedURL types.String + expectedDiags diag.Diagnostics + }{ + "valid url with https": { + providedURL: types.StringValue("https://dev.my-metrics-endpoint-url.com:9000/metrics"), + expectedDiags: nil, + }, + "invalid empty string": { + providedURL: types.StringValue(""), + expectedDiags: diag.Diagnostics{diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "value must be valid URL with HTTPS", + "A valid URL is required.\n\nGiven Value: \"\"\n", + )}, + }, + "invalid null": { + providedURL: types.StringNull(), + expectedDiags: diag.Diagnostics{diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "value must be valid URL with HTTPS", + "A valid URL is required.\n\nGiven Value: \"\"\n", + )}, + }, + "invalid unknown": { + providedURL: types.StringUnknown(), + expectedDiags: diag.Diagnostics{diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "value must be valid URL with HTTPS", + "A valid URL is required.\n\nGiven Value: \"\"\n", + )}, + }, + "invalid not a url": { + providedURL: types.StringValue("this is not a url"), + expectedDiags: diag.Diagnostics{diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "value must be valid URL with HTTPS", + "A URL was provided, protocol must be HTTPS.\n\nGiven Value: \"this is not a url\"\n", + )}, + }, + "invalid leading space url": { + providedURL: types.StringValue(" https://leading.space"), + expectedDiags: diag.Diagnostics{diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "value must be valid URL with HTTPS", + "A string value was provided that is not a valid URL.\n\nGiven Value: https://leading.space\nError: parse \" https://leading.space\": first path segment in URL cannot contain colon", + )}, + }, + "invalid url without https": { + providedURL: types.StringValue("www.google.com"), + expectedDiags: diag.Diagnostics{diag.NewAttributeErrorDiagnostic( + path.Root("test"), + "value must be valid URL with HTTPS", + "A URL was provided, protocol must be HTTPS.\n\nGiven Value: \"www.google.com\"\n", + )}, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + res := validator.StringResponse{} + connections.HTTPSURLValidator{}.ValidateString( + context.Background(), + validator.StringRequest{ + ConfigValue: tc.providedURL, + Path: path.Root("test"), + }, + &res) + + assert.Equal(t, tc.expectedDiags, res.Diagnostics) + }) + } +} diff --git a/pkg/provider/configure_clients.go b/pkg/provider/configure_clients.go index 3afc2811d..a883fc760 100644 --- a/pkg/provider/configure_clients.go +++ b/pkg/provider/configure_clients.go @@ -21,6 +21,7 @@ import ( "github.com/go-openapi/strfmt" "github.com/grafana/terraform-provider-grafana/v3/internal/common" + "github.com/grafana/terraform-provider-grafana/v3/internal/common/connectionsapi" "github.com/grafana/terraform-provider-grafana/v3/internal/resources/grafana" "github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/terraform-plugin-framework/attr" @@ -58,6 +59,11 @@ func CreateClients(providerConfig ProviderConfig) (*common.Client, error) { onCallClient.UserAgent = providerConfig.UserAgent.ValueString() c.OnCallClient = onCallClient } + if !providerConfig.ConnectionsAccessToken.IsNull() { + if err := createConnectionsClient(c, providerConfig); err != nil { + return nil, err + } + } grafana.StoreDashboardSHA256 = providerConfig.StoreDashboardSha256.ValueBool() @@ -172,6 +178,19 @@ func createOnCallClient(providerConfig ProviderConfig) (*onCallAPI.Client, error return onCallAPI.New(providerConfig.OncallURL.ValueString(), providerConfig.OncallAccessToken.ValueString()) } +func createConnectionsClient(client *common.Client, providerConfig ProviderConfig) error { + apiClient, err := connectionsapi.NewClient( + providerConfig.ConnectionsAccessToken.ValueString(), + providerConfig.ConnectionsURL.ValueString(), + getRetryClient(providerConfig), + ) + if err != nil { + return err + } + client.ConnectionsAPIClient = apiClient + return nil +} + // Sets a custom HTTP Header on all requests coming from the Grafana Terraform Provider to Grafana-Terraform-Provider: true // in addition to any headers set within the `http_headers` field or the `GRAFANA_HTTP_HEADERS` environment variable func getHTTPHeadersMap(providerConfig ProviderConfig) (map[string]string, error) { diff --git a/pkg/provider/framework_provider.go b/pkg/provider/framework_provider.go index bb412a501..fc0428c8f 100644 --- a/pkg/provider/framework_provider.go +++ b/pkg/provider/framework_provider.go @@ -40,6 +40,9 @@ type ProviderConfig struct { OncallAccessToken types.String `tfsdk:"oncall_access_token"` OncallURL types.String `tfsdk:"oncall_url"` + ConnectionsAccessToken types.String `tfsdk:"connections_access_token"` + ConnectionsURL types.String `tfsdk:"connections_url"` + UserAgent types.String `tfsdk:"-"` Version types.String `tfsdk:"-"` } @@ -58,6 +61,8 @@ func (c *ProviderConfig) SetDefaults() error { c.SMURL = envDefaultFuncString(c.SMURL, "GRAFANA_SM_URL", "https://synthetic-monitoring-api.grafana.net") c.OncallAccessToken = envDefaultFuncString(c.OncallAccessToken, "GRAFANA_ONCALL_ACCESS_TOKEN") c.OncallURL = envDefaultFuncString(c.OncallURL, "GRAFANA_ONCALL_URL", "https://oncall-prod-us-central-0.grafana.net/oncall") + c.ConnectionsAccessToken = envDefaultFuncString(c.ConnectionsAccessToken, "GRAFANA_CONNECTIONS_ACCESS_TOKEN") + c.ConnectionsURL = envDefaultFuncString(c.ConnectionsURL, "GRAFANA_CONNECTIONS_URL", "https://connections-api.grafana.net") if c.StoreDashboardSha256, err = envDefaultFuncBool(c.StoreDashboardSha256, "GRAFANA_STORE_DASHBOARD_SHA256", false); err != nil { return fmt.Errorf("failed to parse GRAFANA_STORE_DASHBOARD_SHA256: %w", err) } @@ -191,6 +196,16 @@ func (p *frameworkProvider) Schema(_ context.Context, _ provider.SchemaRequest, Optional: true, MarkdownDescription: "An Grafana OnCall backend address. May alternatively be set via the `GRAFANA_ONCALL_URL` environment variable.", }, + + "connections_access_token": schema.StringAttribute{ + Optional: true, + Sensitive: true, + MarkdownDescription: "A Grafana Connections API access token. May alternatively be set via the `GRAFANA_CONNECTIONS_ACCESS_TOKEN` environment variable.", + }, + "connections_url": schema.StringAttribute{ + Optional: true, + MarkdownDescription: "A Grafana Connections API backend address. May alternatively be set via the `GRAFANA_CONNECTIONS_URL` environment variable.", + }, }, } } diff --git a/pkg/provider/legacy_provider.go b/pkg/provider/legacy_provider.go index 627dc3b67..2cd91df14 100644 --- a/pkg/provider/legacy_provider.go +++ b/pkg/provider/legacy_provider.go @@ -136,6 +136,18 @@ func Provider(version string) *schema.Provider { Description: "An Grafana OnCall backend address. May alternatively be set via the `GRAFANA_ONCALL_URL` environment variable.", ValidateFunc: validation.IsURLWithHTTPorHTTPS, }, + "connections_access_token": { + Type: schema.TypeString, + Optional: true, + Sensitive: true, + Description: "A Grafana Connections API access token. May alternatively be set via the `GRAFANA_CONNECTIONS_ACCESS_TOKEN` environment variable.", + }, + "connections_url": { + Type: schema.TypeString, + Optional: true, + Description: "A Grafana Connections API backend address. May alternatively be set via the `GRAFANA_CONNECTIONS_URL` environment variable.", + ValidateFunc: validation.IsURLWithHTTPorHTTPS, + }, }, ResourcesMap: legacySDKResources(), @@ -204,6 +216,8 @@ func configure(version string, p *schema.Provider) func(context.Context, *schema SMURL: stringValueOrNull(d, "sm_url"), OncallAccessToken: stringValueOrNull(d, "oncall_access_token"), OncallURL: stringValueOrNull(d, "oncall_url"), + ConnectionsAccessToken: stringValueOrNull(d, "connections_access_token"), + ConnectionsURL: stringValueOrNull(d, "connections_url"), StoreDashboardSha256: boolValueOrNull(d, "store_dashboard_sha256"), HTTPHeaders: headers, Retries: int64ValueOrNull(d, "retries"),