diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8aaeff7f..69ed39fa 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -61,6 +61,8 @@ jobs: timeout-minutes: 10 env: HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} + HONEYCOMB_KEY_ID: ${{ secrets.HONEYCOMB_KEY_ID }} + HONEYCOMB_KEY_SECRET: ${{ secrets.HONEYCOMB_KEY_SECRET }} HONEYCOMB_DATASET: testacc run: go test -v -coverprofile=client-coverage.txt -covermode=atomic ./client/... @@ -73,7 +75,10 @@ jobs: timeout-minutes: 10 env: HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY }} + HONEYCOMB_KEY_ID: ${{ secrets.HONEYCOMB_KEY_ID }} + HONEYCOMB_KEY_SECRET: ${{ secrets.HONEYCOMB_KEY_SECRET }} HONEYCOMB_DATASET: testacc + HONEYCOMB_ENVIRONMENT_ID: hcaen_01j1d7t02zf7wgw7q89z3t60vf # TODO: remove and do a lookup or create in tests TF_ACC: 1 TF_ACC_TERRAFORM_VERSION: ${{ env.TERRAFORM_VERSION }} run: go test -v -coverprofile=tf-coverage.txt -covermode=atomic ./internal/... ./honeycombio/... @@ -112,6 +117,8 @@ jobs: env: HONEYCOMB_API_ENDPOINT: https://api.eu1.honeycomb.io HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY_EU }} + HONEYCOMB_KEY_ID: ${{ secrets.HONEYCOMB_KEY_ID_EU }} + HONEYCOMB_KEY_SECRET: ${{ secrets.HONEYCOMB_KEY_SECRET_EU }} HONEYCOMB_DATASET: testacc run: go test -v -coverprofile=client-coverage.txt -covermode=atomic ./client/... @@ -125,7 +132,10 @@ jobs: env: HONEYCOMB_API_ENDPOINT: https://api.eu1.honeycomb.io HONEYCOMB_API_KEY: ${{ secrets.HONEYCOMB_API_KEY_EU }} + HONEYCOMB_KEY_ID: ${{ secrets.HONEYCOMB_KEY_ID_EU }} + HONEYCOMB_KEY_SECRET: ${{ secrets.HONEYCOMB_KEY_SECRET_EU }} HONEYCOMB_DATASET: testacc + HONEYCOMB_ENVIRONMENT_ID: hcben_01hvp28qbgzaeebbz29qvbb7gt # TODO: remove and do a lookup or create in tests TF_ACC: 1 TF_ACC_TERRAFORM_VERSION: ${{ env.TERRAFORM_VERSION }} run: go test -v -coverprofile=tf-coverage.txt -covermode=atomic ./internal/... ./honeycombio/... diff --git a/client/board_test.go b/client/board_test.go index a1624f17..c892ee08 100644 --- a/client/board_test.go +++ b/client/board_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -139,7 +140,7 @@ func TestBoards(t *testing.T) { t.Run("Fail to get deleted Board", func(t *testing.T) { _, err := c.Boards.Get(ctx, b.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/burn_alert_test.go b/client/burn_alert_test.go index bfa983f4..60c62c01 100644 --- a/client/burn_alert_test.go +++ b/client/burn_alert_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -219,7 +220,7 @@ func TestBurnAlerts(t *testing.T) { t.Run(fmt.Sprintf("Fail to GET a deleted burn alert: %s", testName), func(t *testing.T) { _, err := c.BurnAlerts.Get(ctx, dataset, burnAlert.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/client.go b/client/client.go index 89970901..8a439cb9 100644 --- a/client/client.go +++ b/client/client.go @@ -19,6 +19,8 @@ import ( cleanhttp "github.com/hashicorp/go-cleanhttp" retryablehttp "github.com/hashicorp/go-retryablehttp" + + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) const ( @@ -221,7 +223,7 @@ func (c *Client) Do(ctx context.Context, method, path string, requestBody, respo defer resp.Body.Close() if !(resp.StatusCode >= 200 && resp.StatusCode <= 299) { - return errorFromResponse(resp) + return hnyerr.FromResponse(resp) } if responseBody != nil { err = json.NewDecoder(resp.Body).Decode(responseBody) diff --git a/client/client_test.go b/client/client_test.go index 1364ce4f..249b17c7 100644 --- a/client/client_test.go +++ b/client/client_test.go @@ -2,6 +2,8 @@ package client_test import ( "context" + "fmt" + "net/http" "os" "testing" @@ -10,6 +12,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) const testUserAgent = "go-honeycombio/test" @@ -90,3 +93,47 @@ func TestClient_EndpointURL(t *testing.T) { assert.Equal(t, endpointUrl, c.EndpointURL().String()) } + +func TestClient_Errors(t *testing.T) { + t.Parallel() + + var de errors.DetailedError + ctx := context.Background() + c := newTestClient(t) + + t.Run("Post with no body should fail with 400 unparseable", func(t *testing.T) { + err := c.Do(ctx, "POST", "/1/boards/", nil, nil) + require.Error(t, err) + require.ErrorAs(t, err, &de) + assert.Equal(t, http.StatusBadRequest, de.Status) + assert.Equal(t, fmt.Sprintf("%s/problems/unparseable", c.EndpointURL()), de.Type) + assert.Equal(t, "The request body could not be parsed.", de.Title) + assert.Equal(t, "could not parse request body", de.Message) + }) + + t.Run("Get into non-existent dataset should fail with 404 'Dataset not found'", func(t *testing.T) { + _, err := c.Markers.Get(ctx, "non-existent-dataset", "abcd1234") + require.Error(t, err) + require.ErrorAs(t, err, &de) + assert.Equal(t, http.StatusNotFound, de.Status) + assert.Equal(t, fmt.Sprintf("%s/problems/not-found", c.EndpointURL()), de.Type) + assert.Equal(t, "The requested resource cannot be found.", de.Title) + assert.Equal(t, "Dataset not found", de.Message) + }) + + t.Run("Creating a dataset without a name should return a validation error", func(t *testing.T) { + createDatasetRequest := &client.Dataset{} + _, err := c.Datasets.Create(ctx, createDatasetRequest) + require.Error(t, err) + require.ErrorAs(t, err, &de) + assert.Equal(t, http.StatusUnprocessableEntity, de.Status) + assert.Equal(t, fmt.Sprintf("%s/problems/validation-failed", c.EndpointURL()), de.Type) + assert.Equal(t, "The provided input is invalid.", de.Title) + assert.Equal(t, "The provided input is invalid.", de.Message) + assert.Len(t, de.Details, 1) + assert.Equal(t, "missing", de.Details[0].Code) + assert.Equal(t, "name", de.Details[0].Field) + assert.Equal(t, "cannot be blank", de.Details[0].Description) + assert.Equal(t, "missing name - cannot be blank", de.Error()) + }) +} diff --git a/client/column_test.go b/client/column_test.go index d836ca5e..c47fb62e 100644 --- a/client/column_test.go +++ b/client/column_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -95,7 +96,7 @@ func TestColumns(t *testing.T) { t.Run("Fail to get deleted Column", func(t *testing.T) { _, err := c.Columns.Get(ctx, dataset, column.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/dataset_test.go b/client/dataset_test.go index abcabd9b..e676ef61 100644 --- a/client/dataset_test.go +++ b/client/dataset_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func TestDatasets(t *testing.T) { @@ -55,7 +56,7 @@ func TestDatasets(t *testing.T) { t.Run("Fail to Get bogus Dataset", func(t *testing.T) { _, err := c.Datasets.Get(ctx, "does-not-exist") - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/derived_column_test.go b/client/derived_column_test.go index 75c77127..e599e0d8 100644 --- a/client/derived_column_test.go +++ b/client/derived_column_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -45,7 +46,7 @@ func TestDerivedColumns(t *testing.T) { } _, err = c.DerivedColumns.Create(ctx, dataset, data) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.Equal(t, http.StatusConflict, de.Status) @@ -97,7 +98,7 @@ func TestDerivedColumns(t *testing.T) { t.Run("Fail to Get Deleted DC", func(t *testing.T) { _, err := c.DerivedColumns.Get(ctx, dataset, derivedColumn.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/errors.go b/client/errors/errors.go similarity index 54% rename from client/errors.go rename to client/errors/errors.go index 76280311..c70dd101 100644 --- a/client/errors.go +++ b/client/errors/errors.go @@ -1,13 +1,13 @@ -package client +package errors import ( "encoding/json" - "fmt" - "io" + "errors" "net/http" + + "github.com/hashicorp/jsonapi" ) -// DetailedError is an RFC7807 'Problem Detail' formatted error message. type DetailedError struct { // The HTTP status code of the error. Status int `json:"status,omitempty"` @@ -52,6 +52,14 @@ func (td ErrorTypeDetail) String() string { return response } +// IsNotFound returns true if the error is an HTTP 404 +func (e *DetailedError) IsNotFound() bool { + if e == nil { + return false + } + return e.Status == http.StatusNotFound +} + // Error returns a pretty-printed representation of the error func (e DetailedError) Error() string { if len(e.Details) > 0 { @@ -60,7 +68,8 @@ func (e DetailedError) Error() string { for index, details := range e.Details { response += details.String() - // If we haven't reached the end of the list of error details, add a newline separator between each error + // If we haven't reached the end of the list of error details, + // add a newline separator between each error if index < len(e.Details)-1 { response += "\n" } @@ -72,34 +81,57 @@ func (e DetailedError) Error() string { return e.Message } -// IsNotFound returns true if the error is an HTTP 404 -func (e *DetailedError) IsNotFound() bool { - if e == nil { - return false +func FromResponse(r *http.Response) error { + if r == nil { + return errors.New("invalid response") } - return e.Status == http.StatusNotFound -} -func errorFromResponse(resp *http.Response) error { - e, err := io.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("unable to read response body: %w", err) - } + switch r.Header.Get("Content-Type") { + case jsonapi.MediaType: + var detailedError DetailedError - var detailedErr DetailedError - err = json.Unmarshal(e, &detailedErr) - if err != nil { - // we failed to parse the body as a DetailedError, so build one from what we know - return DetailedError{ - Status: resp.StatusCode, - Message: resp.Status, + errPayload := new(jsonapi.ErrorsPayload) + err := json.NewDecoder(r.Body).Decode(errPayload) + if err != nil || len(errPayload.Errors) == 0 { + return DetailedError{ + Status: r.StatusCode, + Message: r.Status, + } } - } - // quick sanity check to make sure we got a StatusCode - if detailedErr.Status == 0 { - detailedErr.Status = resp.StatusCode - } + detailedError = DetailedError{ + Status: r.StatusCode, + Title: errPayload.Errors[0].Title, + } + if len(errPayload.Errors) == 1 { + // If there's only one error we don't need to build up details + detailedError.Message = errPayload.Errors[0].Detail + detailedError.Type = errPayload.Errors[0].Code + } else { + details := make([]ErrorTypeDetail, len(errPayload.Errors)) + for i, e := range errPayload.Errors { + details[i] = ErrorTypeDetail{ + Code: e.Code, + Description: e.Detail, + // TODO: field when we have it via pointer + } + } + } + return detailedError + default: + var detailedError DetailedError + if err := json.NewDecoder(r.Body).Decode(&detailedError); err != nil { + // If we can't decode the error, return a generic error + return DetailedError{ + Status: r.StatusCode, + Message: r.Status, + } + } - return detailedErr + // sanity check: ensure we have the status code set + if detailedError.Status == 0 { + detailedError.Status = r.StatusCode + } + return detailedError + } } diff --git a/client/errors_test.go b/client/errors/errors_test.go similarity index 51% rename from client/errors_test.go rename to client/errors/errors_test.go index bf3240ce..264204f7 100644 --- a/client/errors_test.go +++ b/client/errors/errors_test.go @@ -1,74 +1,24 @@ -package client_test +package errors import ( - "context" - "fmt" - "net/http" "testing" "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/honeycombio/terraform-provider-honeycombio/client" ) -func TestClient_ParseDetailedError(t *testing.T) { - t.Parallel() - - var de client.DetailedError - ctx := context.Background() - c := newTestClient(t) - - t.Run("Post with no body should fail with 400 unparseable", func(t *testing.T) { - err := c.Do(ctx, "POST", "/1/boards/", nil, nil) - require.Error(t, err) - require.ErrorAs(t, err, &de) - assert.Equal(t, http.StatusBadRequest, de.Status) - assert.Equal(t, fmt.Sprintf("%s/problems/unparseable", c.EndpointURL()), de.Type) - assert.Equal(t, "The request body could not be parsed.", de.Title) - assert.Equal(t, "could not parse request body", de.Message) - }) - - t.Run("Get into non-existent dataset should fail with 404 'Dataset not found'", func(t *testing.T) { - _, err := c.Markers.Get(ctx, "non-existent-dataset", "abcd1234") - require.Error(t, err) - require.ErrorAs(t, err, &de) - assert.Equal(t, http.StatusNotFound, de.Status) - assert.Equal(t, fmt.Sprintf("%s/problems/not-found", c.EndpointURL()), de.Type) - assert.Equal(t, "The requested resource cannot be found.", de.Title) - assert.Equal(t, "Dataset not found", de.Message) - }) - - t.Run("Creating a dataset without a name should return a validation error", func(t *testing.T) { - createDatasetRequest := &client.Dataset{} - _, err := c.Datasets.Create(ctx, createDatasetRequest) - require.Error(t, err) - require.ErrorAs(t, err, &de) - assert.Equal(t, http.StatusUnprocessableEntity, de.Status) - assert.Equal(t, fmt.Sprintf("%s/problems/validation-failed", c.EndpointURL()), de.Type) - assert.Equal(t, "The provided input is invalid.", de.Title) - assert.Equal(t, "The provided input is invalid.", de.Message) - assert.Len(t, de.Details, 1) - assert.Equal(t, "missing", de.Details[0].Code) - assert.Equal(t, "name", de.Details[0].Field) - assert.Equal(t, "cannot be blank", de.Details[0].Description) - assert.Equal(t, "missing name - cannot be blank", de.Error()) - }) -} - func TestErrors_DetailedError_Error(t *testing.T) { t.Parallel() testCases := []struct { name string - input client.DetailedError + input DetailedError expectedOutput string }{ { name: "multiple details get separated by newline", - input: client.DetailedError{ + input: DetailedError{ Message: "test message", - Details: []client.ErrorTypeDetail{ + Details: []ErrorTypeDetail{ { Code: "test code1", Field: "test_field1", @@ -85,17 +35,17 @@ func TestErrors_DetailedError_Error(t *testing.T) { }, { name: "empty details returns message", - input: client.DetailedError{ + input: DetailedError{ Message: "test message", - Details: []client.ErrorTypeDetail{}, + Details: []ErrorTypeDetail{}, }, expectedOutput: "test message", }, { name: "one item in details has no newlines", - input: client.DetailedError{ + input: DetailedError{ Message: "test message", - Details: []client.ErrorTypeDetail{ + Details: []ErrorTypeDetail{ { Code: "test code", Field: "test_field", @@ -120,12 +70,12 @@ func TestErrors_ErrorTypeDetail_String(t *testing.T) { testCases := []struct { name string - input client.ErrorTypeDetail + input ErrorTypeDetail expectedOutput string }{ { name: "happy path: Code, Field, and Description present", - input: client.ErrorTypeDetail{ + input: ErrorTypeDetail{ Code: "test code", Field: "test_field", Description: "test description", @@ -134,12 +84,12 @@ func TestErrors_ErrorTypeDetail_String(t *testing.T) { }, { name: "all fields blank returns empty string", - input: client.ErrorTypeDetail{}, + input: ErrorTypeDetail{}, expectedOutput: "", }, { name: "empty Code", - input: client.ErrorTypeDetail{ + input: ErrorTypeDetail{ Field: "test_field", Description: "test description", }, @@ -147,21 +97,21 @@ func TestErrors_ErrorTypeDetail_String(t *testing.T) { }, { name: "empty Code and Field", - input: client.ErrorTypeDetail{ + input: ErrorTypeDetail{ Description: "test description", }, expectedOutput: "test description", }, { name: "empty Code and Description", - input: client.ErrorTypeDetail{ + input: ErrorTypeDetail{ Field: "test_field", }, expectedOutput: "test_field", }, { name: "empty Field", - input: client.ErrorTypeDetail{ + input: ErrorTypeDetail{ Code: "test code", Description: "test description", }, @@ -169,14 +119,14 @@ func TestErrors_ErrorTypeDetail_String(t *testing.T) { }, { name: "empty Field and Description", - input: client.ErrorTypeDetail{ + input: ErrorTypeDetail{ Code: "test code", }, expectedOutput: "test code", }, { name: "empty Description", - input: client.ErrorTypeDetail{ + input: ErrorTypeDetail{ Code: "test code", Field: "test_field", }, diff --git a/client/marker.go b/client/marker.go index 0f1470f7..6b56337e 100644 --- a/client/marker.go +++ b/client/marker.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "time" + + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) // Markers describes all the marker-related methods that the Honeycomb API @@ -88,7 +90,7 @@ func (s *markers) Get(ctx context.Context, dataset string, id string) (*Marker, return &m, nil } } - return nil, DetailedError{ + return nil, errors.DetailedError{ Status: http.StatusNotFound, Message: "Marker Not Found.", } diff --git a/client/marker_settings.go b/client/marker_settings.go index 7936b479..af8472e6 100644 --- a/client/marker_settings.go +++ b/client/marker_settings.go @@ -5,6 +5,8 @@ import ( "fmt" "net/http" "time" + + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) // MarkerSettings describes all the markerType-related methods that the Honeycomb API @@ -75,7 +77,7 @@ func (s *markerSettings) Get(ctx context.Context, dataset string, id string) (*M return &m, nil } } - return nil, DetailedError{ + return nil, errors.DetailedError{ Status: http.StatusNotFound, Message: "Marker Setting Not Found.", } diff --git a/client/marker_settings_test.go b/client/marker_settings_test.go index cecce7cb..5262bb70 100644 --- a/client/marker_settings_test.go +++ b/client/marker_settings_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -86,7 +87,7 @@ func TestMarkerSettings(t *testing.T) { t.Run("Fail to Get deleted Marker Setting", func(t *testing.T) { _, err := c.MarkerSettings.Get(ctx, dataset, m.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/marker_test.go b/client/marker_test.go index fb8e1117..4be59a9b 100644 --- a/client/marker_test.go +++ b/client/marker_test.go @@ -11,6 +11,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -94,7 +95,7 @@ func TestMarkers(t *testing.T) { t.Run("Fail to Get deleted Marker", func(t *testing.T) { _, err := c.Markers.Get(ctx, dataset, m.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/query_annotation_test.go b/client/query_annotation_test.go index f36dc572..b331eeff 100644 --- a/client/query_annotation_test.go +++ b/client/query_annotation_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func TestQueryAnnotations(t *testing.T) { @@ -81,7 +82,7 @@ func TestQueryAnnotations(t *testing.T) { t.Run("Fail to Get deleted Query Annotation", func(t *testing.T) { _, err := c.QueryAnnotations.Get(ctx, dataset, queryAnnotation.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/query_result_test.go b/client/query_result_test.go index fdfa2e9b..2c472383 100644 --- a/client/query_result_test.go +++ b/client/query_result_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func TestQueryResults(t *testing.T) { @@ -54,7 +55,7 @@ func TestQueryResults(t *testing.T) { t.Run("Fail to Get bogus Query Result", func(t *testing.T) { err := c.QueryResults.Get(ctx, dataset, &client.QueryResult{ID: "abcd1234"}) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/recipient_test.go b/client/recipient_test.go index 319ceb53..289b0291 100644 --- a/client/recipient_test.go +++ b/client/recipient_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -73,7 +74,7 @@ func TestRecipientsEmail(t *testing.T) { t.Run("Fail to Get deleted Recipient", func(t *testing.T) { _, err := c.Recipients.Get(ctx, rcpt.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/slo_test.go b/client/slo_test.go index 11b356d8..84fe1865 100644 --- a/client/slo_test.go +++ b/client/slo_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -92,7 +93,7 @@ func TestSLOs(t *testing.T) { t.Run("Fail to Get deleted SLO", func(t *testing.T) { _, err := c.SLOs.Get(ctx, dataset, slo.ID) - var de client.DetailedError + var de errors.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/trigger_test.go b/client/trigger_test.go index de1c641a..50b0ec97 100644 --- a/client/trigger_test.go +++ b/client/trigger_test.go @@ -9,6 +9,7 @@ import ( "github.com/stretchr/testify/require" "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/test" ) @@ -132,7 +133,7 @@ func TestTriggers(t *testing.T) { t.Run("Fail to Get deleted Trigger", func(t *testing.T) { _, err := c.Triggers.Get(ctx, dataset, trigger.ID) - var de client.DetailedError + var de hnyerr.DetailedError require.Error(t, err) require.ErrorAs(t, err, &de) assert.True(t, de.IsNotFound()) diff --git a/client/v2/api_keys.go b/client/v2/api_keys.go new file mode 100644 index 00000000..0253dabf --- /dev/null +++ b/client/v2/api_keys.go @@ -0,0 +1,115 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/jsonapi" + + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" +) + +// Compile-time proof of interface implementation. +var _ APIKeys = (*apiKeys)(nil) + +type APIKeys interface { + Create(ctx context.Context, key *APIKey) (*APIKey, error) + Get(ctx context.Context, id string) (*APIKey, error) + Update(ctx context.Context, key *APIKey) (*APIKey, error) + Delete(ctx context.Context, id string) error + List(ctx context.Context, opts ...ListOption) (*Pager[APIKey], error) +} + +const ( + apiKeysPath = "/2/teams/%s/api-keys" + apiKeysByIDPath = "/2/teams/%s/api-keys/%s" +) + +type apiKeys struct { + client *Client + authinfo *AuthMetadata +} + +func (a *apiKeys) Create(ctx context.Context, k *APIKey) (*APIKey, error) { + r, err := a.client.Do(ctx, + http.MethodPost, + fmt.Sprintf(apiKeysPath, a.authinfo.Team.Slug), + k, + ) + if err != nil { + return nil, err + } + if r.StatusCode != http.StatusCreated { + return nil, errors.FromResponse(r) + } + + key := new(APIKey) + if err := jsonapi.UnmarshalPayload(r.Body, key); err != nil { + return nil, err + } + return key, nil +} + +func (a *apiKeys) Get(ctx context.Context, id string) (*APIKey, error) { + r, err := a.client.Do(ctx, + http.MethodGet, + fmt.Sprintf(apiKeysByIDPath, a.authinfo.Team.Slug, id), + nil, + ) + if err != nil { + return nil, err + } + if r.StatusCode != http.StatusOK { + return nil, errors.FromResponse(r) + } + + key := new(APIKey) + if err := jsonapi.UnmarshalPayload(r.Body, key); err != nil { + return nil, err + } + return key, nil +} + +func (a *apiKeys) Update(ctx context.Context, k *APIKey) (*APIKey, error) { + r, err := a.client.Do(ctx, + http.MethodPatch, + fmt.Sprintf(apiKeysByIDPath, a.authinfo.Team.Slug, k.ID), + k, + ) + if err != nil { + return nil, err + } + if r.StatusCode != http.StatusOK { + return nil, errors.FromResponse(r) + } + + key := new(APIKey) + if err := jsonapi.UnmarshalPayload(r.Body, key); err != nil { + return nil, err + } + return key, nil +} + +func (a *apiKeys) Delete(ctx context.Context, id string) error { + r, err := a.client.Do(ctx, + http.MethodDelete, + fmt.Sprintf(apiKeysByIDPath, a.authinfo.Team.Slug, id), + nil, + ) + if err != nil { + return err + } + if r.StatusCode != http.StatusNoContent { + return errors.FromResponse(r) + } + return nil +} + +func (a *apiKeys) List(ctx context.Context, os ...ListOption) (*Pager[APIKey], error) { + return NewPager[APIKey]( + a.client, + fmt.Sprintf(apiKeysPath, a.authinfo.Team.Slug), + os..., + ) +} diff --git a/client/v2/api_keys_test.go b/client/v2/api_keys_test.go new file mode 100644 index 00000000..5aa4b614 --- /dev/null +++ b/client/v2/api_keys_test.go @@ -0,0 +1,127 @@ +package v2 + +import ( + "context" + "fmt" + "math" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" +) + +func TestClient_APIKeys(t *testing.T) { + ctx := context.Background() + c := newTestClient(t) + + // create a new key + k, err := c.APIKeys.Create(ctx, &APIKey{ + Name: helper.ToPtr("test key"), + KeyType: "ingest", + Environment: &Environment{ + ID: testEnvironmentSlug, + }, + Permissions: &APIKeyPermissions{ + CreateDatasets: true, + }, + }) + require.NoError(t, err) + assert.NotEmpty(t, k.ID) + assert.NotEmpty(t, k.Secret) + assert.Equal(t, "test key", *k.Name) + assert.False(t, *k.Disabled) + assert.True(t, k.Permissions.CreateDatasets) + + // read the key back and compare + key, err := c.APIKeys.Get(ctx, k.ID) + require.NoError(t, err) + assert.Equal(t, k.ID, key.ID) + assert.Equal(t, k.Name, key.Name) + assert.Equal(t, k.KeyType, key.KeyType) + assert.Equal(t, k.Disabled, key.Disabled) + assert.Equal(t, k.Environment.ID, key.Environment.ID) + assert.Equal(t, k.Permissions.CreateDatasets, key.Permissions.CreateDatasets) + assert.WithinDuration(t, k.Timestamps.CreatedAt, key.Timestamps.CreatedAt, 5*time.Second) + assert.WithinDuration(t, k.Timestamps.UpdatedAt, key.Timestamps.UpdatedAt, 5*time.Second) + + // update the key's name and disable it + key, err = c.APIKeys.Update(ctx, &APIKey{ + ID: k.ID, + Name: helper.ToPtr("new name"), + Disabled: helper.ToPtr(true), + }) + require.NoError(t, err) + assert.Equal(t, k.ID, key.ID) + assert.Equal(t, "new name", *key.Name) + assert.True(t, *key.Disabled) + assert.WithinDuration(t, time.Now(), key.Timestamps.UpdatedAt, time.Second) + + // delete the key + err = c.APIKeys.Delete(ctx, k.ID) + require.NoError(t, err) + + // verify that the key was deleted + _, err = c.APIKeys.Get(ctx, k.ID) + var de errors.DetailedError + require.ErrorAs(t, err, &de) + assert.True(t, de.IsNotFound()) +} + +func TestClient_APIKeys_Pagination(t *testing.T) { + ctx := context.Background() + c := newTestClient(t) + + // create a bunch of keys + numKeys := int(math.Floor(1.5 * float64(defaultPageSize))) + testKeys := make([]*APIKey, numKeys) + for i := 0; i < numKeys; i++ { + k, err := c.APIKeys.Create(ctx, &APIKey{ + Name: helper.ToPtr(fmt.Sprintf("testkey-%d", i)), + KeyType: "ingest", + Environment: &Environment{ + ID: testEnvironmentSlug, + }, + }) + testKeys[i] = k + require.NoError(t, err) + } + t.Cleanup(func() { + for _, k := range testKeys { + c.APIKeys.Delete(ctx, k.ID) + } + }) + + t.Run("happy path", func(t *testing.T) { + keys := make([]*APIKey, 0) + pager, err := c.APIKeys.List(ctx) + require.NoError(t, err) + + items, err := pager.Next(ctx) + require.NoError(t, err) + assert.Len(t, items, defaultPageSize, "incorrect number of items") + assert.True(t, pager.HasNext(), "should have more pages") + keys = append(keys, items...) + + for pager.HasNext() { + items, err = pager.Next(ctx) + require.NoError(t, err) + keys = append(keys, items...) + } + assert.Len(t, keys, numKeys) + }) + + t.Run("works with custom page size", func(t *testing.T) { + pageSize := 5 + pager, err := c.APIKeys.List(ctx, PageSize(pageSize)) + require.NoError(t, err) + + items, err := pager.Next(ctx) + require.NoError(t, err) + assert.Len(t, items, pageSize, "incorrect number of items") + assert.True(t, pager.HasNext(), "should have more pages") + }) +} diff --git a/client/v2/client.go b/client/v2/client.go new file mode 100644 index 00000000..05f46f28 --- /dev/null +++ b/client/v2/client.go @@ -0,0 +1,214 @@ +package v2 + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "log" + "net/http" + "net/url" + "os" + "time" + + "github.com/hashicorp/go-retryablehttp" + "github.com/hashicorp/jsonapi" + + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" +) + +const ( + DefaultAPIHost = "https://api.honeycomb.io" + DefaultAPIEndpointEnv = "HONEYCOMB_API_ENDPOINT" + DefaultAPIKeyIDEnv = "HONEYCOMB_KEY_ID" + DefaultAPIKeySecretEnv = "HONEYCOMB_KEY_SECRET" + + defaultUserAgent = "go-honeycomb" +) + +type Config struct { + APIKeyID string + APIKeySecret string + BaseURL string + Debug bool + HTTPClient *http.Client + UserAgent string + skipInitialization bool +} + +type Client struct { + BaseURL *url.URL + Headers http.Header + UserAgent string + + http *retryablehttp.Client + + // API handlers here + APIKeys APIKeys +} + +func NewClient() (*Client, error) { + return NewClientWithConfig(nil) +} + +func NewClientWithConfig(config *Config) (*Client, error) { + if config == nil { + config = &Config{} + } + if config.BaseURL == "" { + host := os.Getenv(DefaultAPIEndpointEnv) + if host == "" { + config.BaseURL = DefaultAPIHost + } else { + config.BaseURL = host + } + } + baseURL, err := url.Parse(config.BaseURL) + if err != nil { + return nil, fmt.Errorf("invalid BaseURL: %w", err) + } + if config.UserAgent == "" { + config.UserAgent = defaultUserAgent + } + + if config.APIKeyID == "" && config.APIKeySecret == "" { + config.APIKeyID = os.Getenv(DefaultAPIKeyIDEnv) + config.APIKeySecret = os.Getenv(DefaultAPIKeySecretEnv) + + // if we still don't have the API key, we'll need to error out + if config.APIKeyID == "" || config.APIKeySecret == "" { + return nil, errors.New("missing API Key ID and Secret pair") + } + } + token := config.APIKeyID + ":" + config.APIKeySecret + + client := &Client{ + UserAgent: config.UserAgent, + BaseURL: baseURL, + Headers: http.Header{ + "Authorization": {"Bearer " + token}, + "Content-Type": {jsonapi.MediaType}, + "User-Agent": {config.UserAgent}, + }, + } + client.http = &retryablehttp.Client{ + Backoff: retryablehttp.DefaultBackoff, + CheckRetry: client.retryHTTPCheck, + ErrorHandler: retryablehttp.PassthroughErrorHandler, + HTTPClient: config.HTTPClient, + RetryWaitMin: 200 * time.Millisecond, + RetryWaitMax: 10 * time.Second, + RetryMax: 15, + } + + if config.Debug { + // if enabled we log all requests and responses to sterr + client.http.Logger = log.New(os.Stderr, "", log.LstdFlags) + client.http.ResponseLogHook = func(l retryablehttp.Logger, resp *http.Response) { + l.Printf("[DEBUG] Request: %s %s", resp.Request.Method, resp.Request.URL.String()) + // TODO: Log request body + } + } + + // early out if we're just creating the client for testing + if config.skipInitialization { + return client, nil + } + + var authinfo *AuthMetadata + authinfo, err = client.AuthInfo(context.Background()) + if err != nil { + return nil, err + } + + // bind API handlers here + client.APIKeys = &apiKeys{client: client, authinfo: authinfo} + + return client, nil +} + +func (c *Client) Do( + ctx context.Context, + method, + path string, + body any, +) (*http.Response, error) { + url, err := c.BaseURL.Parse(path) + if err != nil { + return nil, err + } + + req, err := c.newRequest( + ctx, + method, + url.String(), + body, + ) + if err != nil { + return nil, err + } + + return c.http.Do(req) +} + +func (c *Client) AuthInfo(ctx context.Context) (*AuthMetadata, error) { + r, err := c.Do(ctx, http.MethodGet, "/2/auth", nil) + if err != nil { + return nil, err + } + if r.StatusCode != http.StatusOK { + return nil, hnyerr.FromResponse(r) + } + + auth := new(AuthMetadata) + if err := jsonapi.UnmarshalPayload(r.Body, auth); err != nil { + return nil, err + } + return auth, err +} + +func (c *Client) newRequest( + ctx context.Context, + method, + url string, + body any, +) (*retryablehttp.Request, error) { + var bodyReader io.Reader + if body != nil { + buf := bytes.NewBuffer(nil) + if err := jsonapi.MarshalPayloadWithoutIncluded(buf, body); err != nil { + return nil, err + } + bodyReader = buf + } + req, err := retryablehttp.NewRequestWithContext(ctx, method, url, bodyReader) + if err != nil { + return nil, err + } + for k, h := range c.Headers { + req.Header[k] = append(req.Header[k], h...) + } + + return req, err +} + +func (c *Client) retryHTTPCheck( + ctx context.Context, + r *http.Response, + _ error, +) (bool, error) { + if r == nil || ctx.Err() != nil { + return false, ctx.Err() + } + + switch r.StatusCode { + case http.StatusTooManyRequests: + // TODO: use new retry header timestamps to determine when to retry + return true, nil + case http.StatusBadGateway, http.StatusGatewayTimeout: + return true, nil + default: + return false, nil + } +} diff --git a/client/v2/client_test.go b/client/v2/client_test.go new file mode 100644 index 00000000..c00a5123 --- /dev/null +++ b/client/v2/client_test.go @@ -0,0 +1,141 @@ +package v2 + +import ( + "context" + "net/http" + "testing" + + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/honeycombio/terraform-provider-honeycombio/client/errors" +) + +const ( + testEnvFilePath = "../../.env" + testUserAgent = "go-honeycombio/test" + // testEnvironmentSlug is the environment slug used for testing + // TODO: use the environments API to create and manage a test environment for the tests + testEnvironmentSlug = "ci" +) + +func TestClient_Config(t *testing.T) { + // load environment values from a .env, if available + _ = godotenv.Load(testEnvFilePath) + + t.Run("constructs a default client", func(t *testing.T) { + c, err := NewClient() + require.NoError(t, err) + assert.Equal(t, defaultUserAgent, c.UserAgent) + assert.NotEmpty(t, c.BaseURL.String()) + }) + + t.Run("constructs a client with config overrides", func(t *testing.T) { + c, err := NewClientWithConfig(&Config{ + APIKeyID: "123", + APIKeySecret: "456", + BaseURL: "https://api.example.com", + UserAgent: testUserAgent, + skipInitialization: true, + }) + require.NoError(t, err) + assert.Equal(t, "Bearer 123:456", c.Headers.Get("Authorization")) + assert.Equal(t, "https://api.example.com", c.BaseURL.String()) + assert.Equal(t, testUserAgent, c.UserAgent) + }) + + t.Run("fails to construct a client with an invalid API URL", func(t *testing.T) { + _, err := NewClientWithConfig(&Config{ + BaseURL: "cache_object:foo/bar", + skipInitialization: true, + }) + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid BaseURL") + }) + + t.Run("fails to construct a client without key id and secret pair", func(t *testing.T) { + t.Run("with both missing", func(t *testing.T) { + // load environment values from a .env, if available + _ = godotenv.Load(testEnvFilePath) + t.Setenv(DefaultAPIKeyIDEnv, "") + t.Setenv(DefaultAPIKeySecretEnv, "") + + _, err := NewClient() + require.Error(t, err) + assert.Contains(t, err.Error(), "missing API Key ID and Secret pair") + }) + + t.Run("with key ID missing", func(t *testing.T) { + // load environment values from a .env, if available + _ = godotenv.Load(testEnvFilePath) + t.Setenv(DefaultAPIKeyIDEnv, "") + + _, err := NewClient() + require.Error(t, err) + assert.Contains(t, err.Error(), "missing API Key ID and Secret pair") + }) + + t.Run("with key secret missing", func(t *testing.T) { + // load environment values from a .env, if available + _ = godotenv.Load(testEnvFilePath) + t.Setenv(DefaultAPIKeyIDEnv, "") + + _, err := NewClient() + require.Error(t, err) + assert.Contains(t, err.Error(), "missing API Key ID and Secret pair") + }) + }) +} + +func TestClient_AuthInfo(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("happy path", func(t *testing.T) { + c := newTestClient(t) + metadata, err := c.AuthInfo(ctx) + require.NoError(t, err) + assert.NotEmpty(t, metadata.ID) + assert.NotEmpty(t, metadata.Name) + assert.Equal(t, "management", metadata.KeyType) + assert.False(t, metadata.Disabled) + assert.NotEmpty(t, metadata.Scopes) + if assert.NotNil(t, metadata.Timestamps) { + assert.NotEmpty(t, metadata.Timestamps.CreatedAt) + assert.NotEmpty(t, metadata.Timestamps.UpdatedAt) + } + if assert.NotNil(t, metadata.Team) { + assert.NotEmpty(t, metadata.Team.ID) + assert.NotEmpty(t, metadata.Team.Name) + assert.NotEmpty(t, metadata.Team.Slug) + } + }) + + t.Run("handles unauthorized gracefully", func(t *testing.T) { + _, err := NewClientWithConfig(&Config{ + APIKeyID: "foo", + APIKeySecret: "bar", + UserAgent: testUserAgent, + }) + + var de errors.DetailedError + require.ErrorAs(t, err, &de) + assert.Equal(t, http.StatusUnauthorized, de.Status) + }) +} + +func newTestClient(t *testing.T) *Client { + t.Helper() + + // load environment values from a .env, if available + _ = godotenv.Load(testEnvFilePath) + + c, err := NewClientWithConfig(&Config{ + UserAgent: testUserAgent, + }) + require.NoError(t, err, "failed to create test client") + + return c +} diff --git a/client/v2/models.go b/client/v2/models.go new file mode 100644 index 00000000..6d2a9cd1 --- /dev/null +++ b/client/v2/models.go @@ -0,0 +1,47 @@ +package v2 + +import ( + "time" +) + +type Environment struct { + ID string `jsonapi:"primary,environments"` + Name string `jsonapi:"attr,name"` + Slug string `jsonapi:"attr,slug"` +} + +type Team struct { + ID string `jsonapi:"primary,teams"` + Name string `jsonapi:"attr,name"` + Slug string `jsonapi:"attr,slug"` +} + +type Timestamps struct { + CreatedAt time.Time `jsonapi:"attr,created,rfc3339,omitempty"` + UpdatedAt time.Time `jsonapi:"attr,updated,rfc3339,omitempty"` +} + +type AuthMetadata struct { + ID string `jsonapi:"primary,api-keys"` + Name string `jsonapi:"attr,name"` + KeyType string `jsonapi:"attr,key_type"` + Disabled bool `jsonapi:"attr,disabled"` + Scopes []string `jsonapi:"attr,scopes"` + Timestamps *Timestamps `jsonapi:"attr,timestamps"` + Team *Team `jsonapi:"relation,team"` +} + +type APIKey struct { + ID string `jsonapi:"primary,api-keys,omitempty"` + Name *string `jsonapi:"attr,name,omitempty"` + KeyType string `jsonapi:"attr,key_type,omitempty"` + Disabled *bool `jsonapi:"attr,disabled,omitempty"` + Secret string `jsonapi:"attr,secret,omitempty"` + Permissions *APIKeyPermissions `jsonapi:"attr,permissions,omitempty"` + Timestamps *Timestamps `jsonapi:"attr,timestamps,omitempty"` + Environment *Environment `jsonapi:"relation,environment"` +} + +type APIKeyPermissions struct { // TODO: convert to proper type + CreateDatasets bool `json:"create_datasets" jsonapi:"attr,create_datasets"` +} diff --git a/client/v2/pagination.go b/client/v2/pagination.go new file mode 100644 index 00000000..84d21ddd --- /dev/null +++ b/client/v2/pagination.go @@ -0,0 +1,136 @@ +package v2 + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "reflect" + + "github.com/google/go-querystring/query" + "github.com/hashicorp/jsonapi" + + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" +) + +type PaginationLinks struct { + Next *string `json:"next,omitempty"` +} + +type ListOptions struct { + // PageSize is the number of results to return per page. + // Default is 20, max is 100. + PageSize int `url:"page[size],omitempty"` +} + +type ListOption func(*ListOptions) + +const defaultPageSize = 20 + +func PageSize(size int) ListOption { + return func(po *ListOptions) { po.PageSize = size } +} + +type Pager[T any] struct { + client *Client + next *string + opts ListOptions +} + +func NewPager[T any]( + c *Client, + url string, + os ...ListOption, +) (*Pager[T], error) { + var opts ListOptions + for _, o := range os { + o(&opts) + } + if opts.PageSize == 0 { + opts.PageSize = defaultPageSize + } + + u, err := c.BaseURL.Parse(url) + if err != nil { + return nil, err + } + // add any options to the URL + v, err := query.Values(opts) + if err != nil { + return nil, err + } + u.RawQuery = v.Encode() + nextUrl := u.RequestURI() + + return &Pager[T]{ + client: c, + next: &nextUrl, + opts: opts, + }, nil +} + +// HasNext returns true if there are more results to fetch. +func (p *Pager[T]) HasNext() bool { return p.next != nil } + +// Next fetches the next page of results. +func (p *Pager[T]) Next(ctx context.Context) ([]*T, error) { + if p.next == nil { + return nil, nil + } + r, err := p.client.Do( + ctx, + http.MethodGet, + *p.next, + nil, + ) + if err != nil { + return nil, err + } + + if r.StatusCode != http.StatusOK { + return nil, hnyerr.FromResponse(r) + } + pagination, err := parsePagination(r) + if err != nil { + return nil, err + } + + payload, err := jsonapi.UnmarshalManyPayload(r.Body, reflect.TypeOf(new(T))) + if err != nil { + return nil, err + } + items := make([]*T, len(payload)) + for i, obj := range payload { + if item, ok := obj.(*T); ok { + items[i] = item + } + } + + // update 'next' for the next fetch + p.next = pagination.Next + + return items, nil +} + +func parsePagination(r *http.Response) (*PaginationLinks, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, err + } + if err = r.Body.Close(); err != nil { + return nil, err + } + + var raw struct { + PaginationLinks `json:"links"` + } + if err := json.NewDecoder(bytes.NewBuffer(body)).Decode(&raw); err != nil { + return nil, err + } + + // put body back to be used properly downstream + r.Body = io.NopCloser(bytes.NewBuffer(body)) + + return &raw.PaginationLinks, nil +} diff --git a/client/v2/pagination_test.go b/client/v2/pagination_test.go new file mode 100644 index 00000000..fbc86688 --- /dev/null +++ b/client/v2/pagination_test.go @@ -0,0 +1,37 @@ +package v2 + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClient_Pagination(t *testing.T) { + t.Parallel() + + c := newTestClient(t) + type TestType struct{} + pagerUrl := "/2/thing/myteam/resource" + + t.Run("returns a Pager with the correct URL and options", func(t *testing.T) { + p, err := NewPager[TestType](c, pagerUrl) + require.NoError(t, err) + + assert.NotNil(t, p.client) + assert.Equal(t, defaultPageSize, p.opts.PageSize) + assert.Equal(t, fmt.Sprintf("%s?page%%5Bsize%%5D=%d", pagerUrl, defaultPageSize), *p.next) + assert.True(t, p.HasNext()) + }) + + t.Run("returns a Pager with the correct URL and options when PageSize is set", func(t *testing.T) { + p, err := NewPager[TestType](c, pagerUrl, PageSize(50)) + require.NoError(t, err) + + assert.NotNil(t, p.client) + assert.Equal(t, 50, p.opts.PageSize) + assert.Equal(t, fmt.Sprintf("%s?page%%5Bsize%%5D=%d", pagerUrl, 50), *p.next) + assert.True(t, p.HasNext()) + }) +} diff --git a/go.mod b/go.mod index 25254d29..4a8639b9 100644 --- a/go.mod +++ b/go.mod @@ -3,8 +3,10 @@ module github.com/honeycombio/terraform-provider-honeycombio go 1.21 require ( + github.com/google/go-querystring v1.1.0 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/go-retryablehttp v0.7.7 + github.com/hashicorp/jsonapi v1.3.1 github.com/hashicorp/terraform-plugin-framework v1.10.0 github.com/hashicorp/terraform-plugin-go v0.23.0 github.com/hashicorp/terraform-plugin-mux v0.16.0 diff --git a/go.sum b/go.sum index 79b4e7f2..1a726492 100644 --- a/go.sum +++ b/go.sum @@ -39,9 +39,12 @@ github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiu github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= +github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= @@ -68,6 +71,8 @@ github.com/hashicorp/hc-install v0.7.0 h1:Uu9edVqjKQxxuD28mR5TikkKDd/p55S8vzPC16 github.com/hashicorp/hc-install v0.7.0/go.mod h1:ELmmzZlGnEcqoUMKUuykHaPCIR1sYLYX+KSggWSKZuA= github.com/hashicorp/hcl/v2 v2.21.0 h1:lve4q/o/2rqwYOgUg3y3V2YPyD1/zkCLGjIV74Jit14= github.com/hashicorp/hcl/v2 v2.21.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= +github.com/hashicorp/jsonapi v1.3.1 h1:GtPvnmcWgYwCuDGvYT5VZBHcUyFdq9lSyCzDjn1DdPo= +github.com/hashicorp/jsonapi v1.3.1/go.mod h1:kWfdn49yCjQvbpnvY1dxxAuAFzISwrrMDQOcu6NsFoM= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVWkd/RG0D2XQ= diff --git a/honeycombio/provider.go b/honeycombio/provider.go index 5fe5c5d5..c7a7c69c 100644 --- a/honeycombio/provider.go +++ b/honeycombio/provider.go @@ -26,6 +26,18 @@ func Provider(version string) *schema.Provider { Optional: true, Sensitive: true, }, + "api_key_id": { // unused in the provider but required to be set for the MuxServer + Type: schema.TypeString, + Description: "The ID portion of the Honeycomb Management API key to use. it can also be set via the `HONEYCOMB_KEY_ID` environment variable.", + Optional: true, + Sensitive: false, + }, + "api_key_secret": { // unused in the provider but required to be set for the MuxServer + Type: schema.TypeString, + Description: "The secret portion of the Honeycomb Management API key to use. It can also be set via the `HONEYCOMB_KEY_SECRET` environment variable.", + Optional: true, + Sensitive: true, + }, "api_url": { Type: schema.TypeString, Description: "Override the URL of the Honeycomb API. Defaults to `https://api.honeycomb.io`. It can also be set via the `HONEYCOMB_API_ENDPOINT` environment variable.", diff --git a/honeycombio/resource_board.go b/honeycombio/resource_board.go index 0619a60d..9a5d35e9 100644 --- a/honeycombio/resource_board.go +++ b/honeycombio/resource_board.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" ) @@ -183,7 +184,7 @@ func resourceBoardCreate(ctx context.Context, d *schema.ResourceData, meta inter func resourceBoardRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { client := meta.(*honeycombio.Client) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError b, err := client.Boards.Get(ctx, d.Id()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/resource_column.go b/honeycombio/resource_column.go index ac37ba4b..a80c7a96 100644 --- a/honeycombio/resource_column.go +++ b/honeycombio/resource_column.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" ) @@ -123,7 +124,7 @@ func resourceColumnRead(ctx context.Context, d *schema.ResourceData, meta interf } // we read by name here to facilitate importing by name instead of ID - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError column, err := client.Columns.GetByKeyName(ctx, dataset, columnName) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/resource_dataset.go b/honeycombio/resource_dataset.go index 59a467cd..daa7d4da 100644 --- a/honeycombio/resource_dataset.go +++ b/honeycombio/resource_dataset.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func newDataset() *schema.Resource { @@ -79,7 +80,7 @@ func resourceDatasetCreate(ctx context.Context, d *schema.ResourceData, meta int func resourceDatasetRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { client := meta.(*honeycombio.Client) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError dataset, err := client.Datasets.Get(ctx, d.Id()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/resource_dataset_definition.go b/honeycombio/resource_dataset_definition.go index 2988ebb5..2c33a3e9 100644 --- a/honeycombio/resource_dataset_definition.go +++ b/honeycombio/resource_dataset_definition.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/hashcode" ) @@ -57,7 +58,7 @@ func resourceDatasetDefinitionRead(ctx context.Context, d *schema.ResourceData, dataset := d.Get("dataset").(string) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError dd, err := client.DatasetDefinitions.Get(ctx, dataset) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/resource_derived_column.go b/honeycombio/resource_derived_column.go index e9449206..09ca02a1 100644 --- a/honeycombio/resource_derived_column.go +++ b/honeycombio/resource_derived_column.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func newDerivedColumn() *schema.Resource { @@ -80,7 +81,7 @@ func resourceDerivedColumnRead(ctx context.Context, d *schema.ResourceData, meta dataset := d.Get("dataset").(string) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError derivedColumn, err := client.DerivedColumns.GetByAlias(ctx, dataset, d.Get("alias").(string)) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/resource_marker.go b/honeycombio/resource_marker.go index a4ff4c25..1a86341b 100644 --- a/honeycombio/resource_marker.go +++ b/honeycombio/resource_marker.go @@ -8,6 +8,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func newMarker() *schema.Resource { @@ -64,7 +65,7 @@ func resourceMarkerCreate(ctx context.Context, d *schema.ResourceData, meta inte func resourceMarkerRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { client := meta.(*honeycombio.Client) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError marker, err := client.Markers.Get(ctx, d.Get("dataset").(string), d.Id()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/resource_marker_setting.go b/honeycombio/resource_marker_setting.go index 3530cce8..815408af 100644 --- a/honeycombio/resource_marker_setting.go +++ b/honeycombio/resource_marker_setting.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func newMarkerSetting() *schema.Resource { @@ -74,7 +75,7 @@ func resourceMarkerSettingCreate(ctx context.Context, d *schema.ResourceData, me func resourceMarkerSettingRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { client := meta.(*honeycombio.Client) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError markerSetting, err := client.MarkerSettings.Get(ctx, d.Get("dataset").(string), d.Id()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/resource_query_annotation.go b/honeycombio/resource_query_annotation.go index c819a06f..725a612a 100644 --- a/honeycombio/resource_query_annotation.go +++ b/honeycombio/resource_query_annotation.go @@ -9,6 +9,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func newQueryAnnotation() *schema.Resource { @@ -95,7 +96,7 @@ func resourceQueryAnnotationRead(ctx context.Context, d *schema.ResourceData, me client := meta.(*honeycombio.Client) dataset := d.Get("dataset").(string) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError queryAnnotation, err := client.QueryAnnotations.Get(ctx, dataset, d.Id()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/resource_slo.go b/honeycombio/resource_slo.go index a037990e..2640918c 100644 --- a/honeycombio/resource_slo.go +++ b/honeycombio/resource_slo.go @@ -10,6 +10,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" ) @@ -102,7 +103,7 @@ func resourceSLORead(ctx context.Context, d *schema.ResourceData, meta interface dataset := d.Get("dataset").(string) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError s, err := client.SLOs.Get(ctx, dataset, d.Id()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/honeycombio/type_helpers.go b/honeycombio/type_helpers.go index 5fdf2a31..4eac9e06 100644 --- a/honeycombio/type_helpers.go +++ b/honeycombio/type_helpers.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) func coerceValueToType(i string) interface{} { @@ -76,7 +77,7 @@ func createRecipient(ctx context.Context, d *schema.ResourceData, meta interface func readRecipient(ctx context.Context, d *schema.ResourceData, meta interface{}, t honeycombio.RecipientType) diag.Diagnostics { client := meta.(*honeycombio.Client) - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError r, err := client.Recipients.Get(ctx, d.Id()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { @@ -218,7 +219,7 @@ func diagFromErr(err error) diag.Diagnostics { return nil } - var detailedErr honeycombio.DetailedError + var detailedErr hnyerr.DetailedError if errors.As(err, &detailedErr) { return diagFromDetailedErr(detailedErr) } @@ -226,7 +227,7 @@ func diagFromErr(err error) diag.Diagnostics { return diag.FromErr(err) } -func diagFromDetailedErr(err honeycombio.DetailedError) diag.Diagnostics { +func diagFromDetailedErr(err hnyerr.DetailedError) diag.Diagnostics { diags := make(diag.Diagnostics, 0, len(err.Details)+1) if len(err.Details) > 0 { for _, d := range err.Details { diff --git a/internal/helper/diag.go b/internal/helper/diag.go index 65d9c311..fd78a622 100644 --- a/internal/helper/diag.go +++ b/internal/helper/diag.go @@ -7,13 +7,13 @@ import ( "github.com/hashicorp/terraform-plugin-framework/diag" - "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" ) // DetailedErrorDiagnostic is a Diagnostic which nicely wraps a client.DetailedError type DetailedErrorDiagnostic struct { summary string - e *client.DetailedError + e *hnyerr.DetailedError } // compile-time check that DetailedErrorDiagnostic implements diag.Diagnostic @@ -21,7 +21,7 @@ var _ diag.Diagnostic = DetailedErrorDiagnostic{} // NewDetailedErrorDiagnostic creates a new DetailedErrorDiagnostic // taking a context-specific summary of the action and a DetailedError. -func NewDetailedErrorDiagnostic(summary string, e *client.DetailedError) DetailedErrorDiagnostic { +func NewDetailedErrorDiagnostic(summary string, e *hnyerr.DetailedError) DetailedErrorDiagnostic { return DetailedErrorDiagnostic{ e: e, summary: summary, @@ -40,7 +40,7 @@ func AddDiagnosticOnError(diag *diag.Diagnostics, summary string, err error) boo return false } - var detailedErr *client.DetailedError + var detailedErr *hnyerr.DetailedError if errors.As(err, &detailedErr) { diag.Append(DetailedErrorDiagnostic{ summary: "Error " + summary, diff --git a/internal/models/api_keys.go b/internal/models/api_keys.go new file mode 100644 index 00000000..0ca64fc2 --- /dev/null +++ b/internal/models/api_keys.go @@ -0,0 +1,24 @@ +package models + +import ( + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type APIKeyResourceModel struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + EnvironmentID types.String `tfsdk:"environment_id"` + Disabled types.Bool `tfsdk:"disabled"` + Permissions types.List `tfsdk:"permissions"` // APIKeyPermissionModel + Secret types.String `tfsdk:"secret"` +} + +type APIKeyPermissionModel struct { + CreateDatasets types.Bool `tfsdk:"create_datasets"` +} + +var APIKeyPermissionsAttrType = map[string]attr.Type{ + "create_datasets": types.BoolType, +} diff --git a/internal/provider/api_key_resource.go b/internal/provider/api_key_resource.go new file mode 100644 index 00000000..1b01dca6 --- /dev/null +++ b/internal/provider/api_key_resource.go @@ -0,0 +1,311 @@ +package provider + +import ( + "context" + "errors" + + "github.com/hashicorp/terraform-plugin-framework-validators/listvalidator" + "github.com/hashicorp/terraform-plugin-framework-validators/stringvalidator" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/booldefault" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/boolplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" + v2client "github.com/honeycombio/terraform-provider-honeycombio/client/v2" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" + "github.com/honeycombio/terraform-provider-honeycombio/internal/models" +) + +// Ensure the implementation satisfies the expected interfaces. +// +// This resource is not implemeting ResourceWithImportState because importing keys +// won't give us the secret portion of the key which is arguably the whole reason +// for the resource. +var ( + _ resource.Resource = &apiKeyResource{} + _ resource.ResourceWithConfigure = &apiKeyResource{} +) + +type apiKeyResource struct { + client *v2client.Client +} + +func NewAPIKeyResource() resource.Resource { + return &apiKeyResource{} +} + +func (*apiKeyResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_api_key" +} + +func (r *apiKeyResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + w := getClientFromResourceRequest(&req) + if w == nil { + return + } + + c, err := w.V2Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + r.client = c +} + +func (*apiKeyResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Description: "API keys are used to authenticate the Honeycomb API.", + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The unique identifier for this API key.", + Computed: true, + Required: false, + Optional: false, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "name": schema.StringAttribute{ + Required: true, + Description: "The name of the API Key.", + Validators: []validator.String{ + stringvalidator.LengthBetween(1, 100), + }, + }, + "type": schema.StringAttribute{ + Required: true, + Description: "The type of the API key.", + Validators: []validator.String{ + stringvalidator.OneOf("ingest"), + }, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "environment_id": schema.StringAttribute{ + Required: true, + Description: "The Environment ID the API key is scoped to.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "disabled": schema.BoolAttribute{ + Optional: true, + Computed: true, + Description: "Whether the API key is disabled.", + Default: booldefault.StaticBool(false), + }, + "secret": schema.StringAttribute{ + Computed: true, + Required: false, + Optional: false, + Sensitive: true, + Description: "The secret portion of the API key. This is only available when creating a new key.", + }, + }, + Blocks: map[string]schema.Block{ + "permissions": schema.ListNestedBlock{ + Description: "Permissions control what actions the API key can perform.", + Validators: []validator.List{ + listvalidator.SizeAtMost(1), + }, + NestedObject: schema.NestedBlockObject{ + Attributes: map[string]schema.Attribute{ + "create_datasets": schema.BoolAttribute{ + Optional: true, + Computed: true, + Default: booldefault.StaticBool(false), + Description: "Allow this key to create missing datasets when sending telemetry.", + PlanModifiers: []planmodifier.Bool{ + boolplanmodifier.RequiresReplace(), + }, + }, + }, + }, + }, + }, + } +} + +func (r *apiKeyResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan models.APIKeyResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + newKey := &v2client.APIKey{ + Name: plan.Name.ValueStringPointer(), + KeyType: plan.Type.ValueString(), + Environment: &v2client.Environment{ID: plan.EnvironmentID.ValueString()}, + Disabled: plan.Disabled.ValueBoolPointer(), + Permissions: expandAPIKeyPermissions(ctx, plan.Permissions, &resp.Diagnostics), + } + + key, err := r.client.APIKeys.Create(ctx, newKey) + if helper.AddDiagnosticOnError(&resp.Diagnostics, "Creating Honeycomb API Key", err) { + return + } + + var state models.APIKeyResourceModel + state.ID = types.StringValue(key.ID) + state.Name = types.StringValue(*key.Name) + state.Type = types.StringValue(key.KeyType) + state.EnvironmentID = types.StringValue(key.Environment.ID) + state.Disabled = types.BoolValue(*key.Disabled) + state.Secret = types.StringValue(key.Secret) + + if !plan.Permissions.IsNull() { + state.Permissions = flattenAPIKeyPermissions(ctx, key.Permissions, &resp.Diagnostics) + } else { + state.Permissions = types.ListNull(types.ObjectType{AttrTypes: models.APIKeyPermissionsAttrType}) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *apiKeyResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + var state models.APIKeyResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + var detailedErr hnyerr.DetailedError + key, err := r.client.APIKeys.Get(ctx, state.ID.ValueString()) + if errors.As(err, &detailedErr) { + if detailedErr.IsNotFound() { + // if not found consider it deleted -- so just remove it from state + resp.State.RemoveResource(ctx) + return + } else { + resp.Diagnostics.Append(helper.NewDetailedErrorDiagnostic( + "Error Reading Honeycomb API Key", + &detailedErr, + )) + } + } else if err != nil { + resp.Diagnostics.AddError( + "Error Reading Honeycomb API Key", + "Unexpected error reading API Key ID "+state.ID.ValueString()+": "+err.Error(), + ) + } + if resp.Diagnostics.HasError() { + return + } + + state.ID = types.StringValue(key.ID) + state.Name = types.StringValue(*key.Name) + state.Type = types.StringValue(key.KeyType) + state.Disabled = types.BoolValue(*key.Disabled) + state.EnvironmentID = types.StringValue(key.Environment.ID) + + if !state.Permissions.IsNull() { + state.Permissions = flattenAPIKeyPermissions(ctx, key.Permissions, &resp.Diagnostics) + } else { + state.Permissions = types.ListNull(types.ObjectType{AttrTypes: models.APIKeyPermissionsAttrType}) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *apiKeyResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + var plan, state models.APIKeyResourceModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + updateRequest := &v2client.APIKey{ + ID: plan.ID.ValueString(), + Name: plan.Name.ValueStringPointer(), + Disabled: plan.Disabled.ValueBoolPointer(), + } + + _, err := r.client.APIKeys.Update(ctx, updateRequest) + if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb API Key", err) { + return + } + + key, err := r.client.APIKeys.Get(ctx, plan.ID.ValueString()) + if helper.AddDiagnosticOnError(&resp.Diagnostics, "Updating Honeycomb API Key", err) { + return + } + + state.ID = types.StringValue(key.ID) + state.Name = types.StringValue(*key.Name) + state.Type = types.StringValue(key.KeyType) + state.Disabled = types.BoolValue(*key.Disabled) + state.EnvironmentID = types.StringValue(key.Environment.ID) + if !state.Permissions.IsNull() { + state.Permissions = flattenAPIKeyPermissions(ctx, key.Permissions, &resp.Diagnostics) + } else { + state.Permissions = types.ListNull(types.ObjectType{AttrTypes: models.APIKeyPermissionsAttrType}) + } + + resp.Diagnostics.Append(resp.State.Set(ctx, state)...) +} + +func (r *apiKeyResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + var state models.APIKeyResourceModel + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + err := r.client.APIKeys.Delete(ctx, state.ID.ValueString()) + var detailedErr hnyerr.DetailedError + if err != nil { + if errors.As(err, &detailedErr) { + resp.Diagnostics.Append(helper.NewDetailedErrorDiagnostic( + "Error Reading Honeycomb API Key", + &detailedErr, + )) + } else { + resp.Diagnostics.AddError( + "Error Deleting Honeycomb API Key", + "Could not delete API Key ID "+state.ID.ValueString()+": "+err.Error(), + ) + } + } +} + +func expandAPIKeyPermissions(ctx context.Context, list types.List, diags *diag.Diagnostics) *v2client.APIKeyPermissions { + var permissions []models.APIKeyPermissionModel + diags.Append(list.ElementsAs(ctx, &permissions, false)...) + if diags.HasError() { + return nil + } + + if len(permissions) == 0 { + return nil + } + + return &v2client.APIKeyPermissions{ + CreateDatasets: permissions[0].CreateDatasets.ValueBool(), + } +} + +func flattenAPIKeyPermissions(ctx context.Context, p *v2client.APIKeyPermissions, diags *diag.Diagnostics) types.List { + if p == nil { + return types.ListNull(types.ObjectType{AttrTypes: models.APIKeyPermissionsAttrType}) + } + + obj, d := types.ObjectValue(models.APIKeyPermissionsAttrType, map[string]attr.Value{ + "create_datasets": types.BoolValue(p.CreateDatasets), + }) + diags.Append(d...) + + result, d := types.ListValueFrom(ctx, types.ObjectType{AttrTypes: models.APIKeyPermissionsAttrType}, []attr.Value{obj}) + diags.Append(d...) + + return result +} diff --git a/internal/provider/api_key_resource_test.go b/internal/provider/api_key_resource_test.go new file mode 100644 index 00000000..13f676e5 --- /dev/null +++ b/internal/provider/api_key_resource_test.go @@ -0,0 +1,110 @@ +package provider + +import ( + "context" + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func TestAcc_APIKeyResource(t *testing.T) { + t.Parallel() + + envID := testAccEnvironment() + + t.Run("happy path", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: testAccPreCheckV2API(t), + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + Config: testAccConfigBasicAPIKeyTest("test key", "false", envID), + Check: resource.ComposeAggregateTestCheckFunc( + testAccEnsureAPIKeyExists(t, "honeycombio_api_key.test"), + resource.TestCheckResourceAttrSet("honeycombio_api_key.test", "id"), + resource.TestCheckResourceAttrSet("honeycombio_api_key.test", "secret"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "name", "test key"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "type", "ingest"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "environment_id", envID), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "disabled", "false"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "permissions.0.create_datasets", "true"), + ), + }, + { // now update the name and disabled state + Config: testAccConfigBasicAPIKeyTest("updated test key", "true", envID), + Check: resource.ComposeAggregateTestCheckFunc( + testAccEnsureAPIKeyExists(t, "honeycombio_api_key.test"), + resource.TestCheckResourceAttrSet("honeycombio_api_key.test", "id"), + resource.TestCheckResourceAttrSet("honeycombio_api_key.test", "secret"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "name", "updated test key"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "type", "ingest"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "environment_id", envID), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "disabled", "true"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "permissions.0.create_datasets", "true"), + ), + }, + }, + }) + }) + + t.Run("default values", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + PreCheck: testAccPreCheckV2API(t), + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + Config: fmt.Sprintf(` +resource "honeycombio_api_key" "test" { + name = "test key" + type = "ingest" + + environment_id = "%s" +}`, envID), + Check: resource.ComposeAggregateTestCheckFunc( + testAccEnsureAPIKeyExists(t, "honeycombio_api_key.test"), + resource.TestCheckResourceAttrSet("honeycombio_api_key.test", "id"), + resource.TestCheckResourceAttrSet("honeycombio_api_key.test", "secret"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "name", "test key"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "type", "ingest"), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "environment_id", envID), + resource.TestCheckResourceAttr("honeycombio_api_key.test", "disabled", "false"), + ), + }, + }, + }) + }) +} + +func testAccConfigBasicAPIKeyTest(name, disabled, envID string) string { + return fmt.Sprintf(` +resource "honeycombio_api_key" "test" { + name = "%s" + type = "ingest" + disabled = %s + + environment_id = "%s" + + permissions { + create_datasets = true + } +}`, name, disabled, envID) +} + +func testAccEnsureAPIKeyExists(t *testing.T, name string) resource.TestCheckFunc { + return func(s *terraform.State) error { + rs, ok := s.RootModule().Resources[name] + if !ok { + return fmt.Errorf("resource not found: %s", name) + } + + client := testAccV2Client(t) + _, err := client.APIKeys.Get(context.Background(), rs.Primary.ID) + if err != nil { + return fmt.Errorf("failed to fetch created API key: %s", err) + } + + return nil + } +} diff --git a/internal/provider/auth_metadata_data_source.go b/internal/provider/auth_metadata_data_source.go index 56315f8a..28663a87 100644 --- a/internal/provider/auth_metadata_data_source.go +++ b/internal/provider/auth_metadata_data_source.go @@ -160,8 +160,18 @@ func (d *authMetadataDataSource) Schema(_ context.Context, _ datasource.SchemaRe } } -func (d *authMetadataDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { - d.client = getClientFromDatasourceRequest(&req) +func (d *authMetadataDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + w := getClientFromDatasourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + d.client = c } func (d *authMetadataDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { diff --git a/internal/provider/burn_alert_resource.go b/internal/provider/burn_alert_resource.go index 5bfd4dae..fbbdf00e 100644 --- a/internal/provider/burn_alert_resource.go +++ b/internal/provider/burn_alert_resource.go @@ -20,6 +20,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" "github.com/honeycombio/terraform-provider-honeycombio/internal/models" ) @@ -45,7 +46,17 @@ func (*burnAlertResource) Metadata(_ context.Context, req resource.MetadataReque } func (r *burnAlertResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - r.client = getClientFromResourceRequest(&req) + w := getClientFromResourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + r.client = c } func (*burnAlertResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { @@ -284,7 +295,7 @@ func (r *burnAlertResource) Read(ctx context.Context, req resource.ReadRequest, } // Read the burn alert, using the values from state - var detailedErr client.DetailedError + var detailedErr hnyerr.DetailedError burnAlert, err := r.client.BurnAlerts.Get(ctx, state.Dataset.ValueString(), state.ID.ValueString()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { @@ -407,7 +418,7 @@ func (r *burnAlertResource) Delete(ctx context.Context, req resource.DeleteReque } // Delete the burn alert, using the values from state - var detailedErr client.DetailedError + var detailedErr hnyerr.DetailedError err := r.client.BurnAlerts.Delete(ctx, state.Dataset.ValueString(), state.ID.ValueString()) if err != nil { if errors.As(err, &detailedErr) { diff --git a/internal/provider/derived_column_data_source.go b/internal/provider/derived_column_data_source.go index 0619c9d4..eee84b77 100644 --- a/internal/provider/derived_column_data_source.go +++ b/internal/provider/derived_column_data_source.go @@ -70,8 +70,18 @@ func (d *derivedColumnDataSource) Schema(_ context.Context, _ datasource.SchemaR } } -func (d *derivedColumnDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { - d.client = getClientFromDatasourceRequest(&req) +func (d *derivedColumnDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + w := getClientFromDatasourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + d.client = c } func (d *derivedColumnDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { diff --git a/internal/provider/derived_columns_data_source.go b/internal/provider/derived_columns_data_source.go index a68d50b3..5d81ad86 100644 --- a/internal/provider/derived_columns_data_source.go +++ b/internal/provider/derived_columns_data_source.go @@ -65,8 +65,18 @@ func (d *derivedColumnsDataSource) Schema(_ context.Context, _ datasource.Schema } } -func (d *derivedColumnsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { - d.client = getClientFromDatasourceRequest(&req) +func (d *derivedColumnsDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + w := getClientFromDatasourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + d.client = c } func (d *derivedColumnsDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 4b029650..b16a8d76 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -2,6 +2,7 @@ package provider import ( "context" + "errors" "fmt" "os" @@ -13,6 +14,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/honeycombio/terraform-provider-honeycombio/client" + v2client "github.com/honeycombio/terraform-provider-honeycombio/client/v2" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/log" ) @@ -28,9 +30,11 @@ type HoneycombioProvider struct { // HoneycombioProviderModel describes the provider data model. type HoneycombioProviderModel struct { - APIKey types.String `tfsdk:"api_key"` - APIUrl types.String `tfsdk:"api_url"` - Debug types.Bool `tfsdk:"debug"` + APIKey types.String `tfsdk:"api_key"` + KeyID types.String `tfsdk:"api_key_id"` + KeySecret types.String `tfsdk:"api_key_secret"` + APIUrl types.String `tfsdk:"api_url"` + Debug types.Bool `tfsdk:"debug"` } func New(version string) provider.Provider { @@ -47,6 +51,16 @@ func (p *HoneycombioProvider) Schema(_ context.Context, _ provider.SchemaRequest Optional: true, Sensitive: true, }, + "api_key_id": schema.StringAttribute{ + MarkdownDescription: "The ID portion of the Honeycomb Management API key to use. it can also be set via the `HONEYCOMB_KEY_ID` environment variable.", + Optional: true, + Sensitive: false, + }, + "api_key_secret": schema.StringAttribute{ + MarkdownDescription: "The secret portion of the Honeycomb Management API key to use. It can also be set via the `HONEYCOMB_KEY_SECRET` environment variable.", + Optional: true, + Sensitive: true, + }, "api_url": schema.StringAttribute{ MarkdownDescription: "Override the URL of the Honeycomb API. Defaults to `https://api.honeycomb.io`. It can also be set via the `HONEYCOMB_API_ENDPOINT` environment variable.", Optional: true, @@ -64,6 +78,7 @@ func (p *HoneycombioProvider) Resources(ctx context.Context) []func() resource.R NewBurnAlertResource, NewTriggerResource, NewQueryResource, + NewAPIKeyResource, } } @@ -90,6 +105,12 @@ func (p *HoneycombioProvider) Configure(ctx context.Context, req provider.Config return } + // TODO: handle case where both API key and key ID/secret are set, but only one is required + // provider will now support three permeations of API key configuration: + // v1 && v2 + // just v1 + // just v2 + if config.APIKey.IsUnknown() { resp.Diagnostics.AddAttributeError( path.Root("api_key"), @@ -126,11 +147,14 @@ func (p *HoneycombioProvider) Configure(ctx context.Context, req provider.Config debug = config.Debug.ValueBool() } + userAgent := fmt.Sprintf("Terraform/%s terraform-provider-honeycombio/%s", req.TerraformVersion, p.version) + + // TODO: only init v1 client if apiKey is set client, err := client.NewClientWithConfig(&client.Config{ APIKey: apiKey, APIUrl: config.APIUrl.ValueString(), Debug: debug, - UserAgent: fmt.Sprintf("Terraform/%s terraform-provider-honeycombio/%s", req.TerraformVersion, p.version), + UserAgent: userAgent, }) if err != nil { resp.Diagnostics.AddError( @@ -141,22 +165,77 @@ func (p *HoneycombioProvider) Configure(ctx context.Context, req provider.Config return } - resp.DataSourceData = client - resp.ResourceData = client + // TODO: only init v2 client if keyID/keySecret are set + keyID := os.Getenv(v2client.DefaultAPIKeyIDEnv) + keySecret := os.Getenv(v2client.DefaultAPIKeySecretEnv) + if keyID == "" { + keyID = config.KeyID.ValueString() + } + if keySecret == "" { + keySecret = config.KeySecret.ValueString() + } + + v2client, err := v2client.NewClientWithConfig(&v2client.Config{ + APIKeyID: keyID, + APIKeySecret: keySecret, + BaseURL: config.APIUrl.ValueString(), + Debug: debug, + UserAgent: userAgent, + }) + if err != nil { + resp.Diagnostics.AddError( + "Unable to create Honeycomb API V2 Client", + "An unexpected error occurred when creating the Honeycomb API V2 client.\n\n "+ + "Honeycomb V2 Client Error: "+err.Error(), + ) + return + } + + cw := &ConfiguredClient{ + v1client: client, + v2client: v2client, + } + + resp.DataSourceData = cw + resp.ResourceData = cw +} + +// ConfiguredClient is a wrapper around the configured Honeycomb API clients. +type ConfiguredClient struct { + v1client *client.Client + v2client *v2client.Client +} + +func (c *ConfiguredClient) V1Client() (*client.Client, error) { + if c.v1client == nil { + return nil, errors.New("no V1 API client configured") // TODO: more descriptive error + } + return c.v1client, nil +} + +func (c *ConfiguredClient) V2Client() (*v2client.Client, error) { + if c.v1client == nil { + return nil, errors.New("no V2 API client configured") // TODO: more descriptive error + } + return c.v2client, nil } -func getClientFromDatasourceRequest(req *datasource.ConfigureRequest) *client.Client { - if req.ProviderData == nil { - return nil +func getClientFromDatasourceRequest(req *datasource.ConfigureRequest) *ConfiguredClient { + if req.ProviderData != nil { + if c, ok := req.ProviderData.(*ConfiguredClient); ok { + return c + } } - //nolint:forcetypeassert - return req.ProviderData.(*client.Client) + // ProviderData hasn't been initialized yet -- so fail gracefully + return nil } -func getClientFromResourceRequest(req *resource.ConfigureRequest) *client.Client { - if req.ProviderData == nil { - return nil +func getClientFromResourceRequest(req *resource.ConfigureRequest) *ConfiguredClient { + if req.ProviderData != nil { + if c, ok := req.ProviderData.(*ConfiguredClient); ok { + return c + } } - //nolint:forcetypeassert - return req.ProviderData.(*client.Client) + // ProviderData hasn't been initialized yet -- so fail gracefully + return nil } diff --git a/internal/provider/provider_test.go b/internal/provider/provider_test.go index d91ab3c2..40c5e756 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -12,6 +12,7 @@ import ( "github.com/joho/godotenv" "github.com/honeycombio/terraform-provider-honeycombio/client" + v2client "github.com/honeycombio/terraform-provider-honeycombio/client/v2" "github.com/honeycombio/terraform-provider-honeycombio/honeycombio" ) @@ -58,10 +59,29 @@ func testAccPreCheck(t *testing.T) func() { } } +func testAccPreCheckV2API(t *testing.T) func() { + return func() { + if _, ok := os.LookupEnv("HONEYCOMB_KEY_ID"); !ok { + t.Fatalf("environment variable HONEYCOMB_KEY_ID must be set to run acceptance tests") + } + if _, ok := os.LookupEnv("HONEYCOMB_KEY_SECRET"); !ok { + t.Fatalf("environment variable HONEYCOMB_KEY_SECRET must be set to run acceptance tests") + } + if _, ok := os.LookupEnv("HONEYCOMB_ENVIRONMENT_ID"); !ok { + t.Fatalf("environment variable HONEYCOMB_ENVIRONMENT_ID must be set to run acceptance tests") + } + } +} + func testAccDataset() string { return os.Getenv("HONEYCOMB_DATASET") } +func testAccEnvironment() string { + // TODO: replace with looking up the environment by name or doing a create at test start + return os.Getenv("HONEYCOMB_ENVIRONMENT_ID") +} + func testAccClient(t *testing.T) *client.Client { c, err := client.NewClient() if err != nil { @@ -70,9 +90,26 @@ func testAccClient(t *testing.T) *client.Client { return c } -// TestMuxServer verifies that a V5 Mux Server can be properly created while -// the Plugin SDK and the Plugin Framework are both in use in the provider -func TestAcc_MuxServer(t *testing.T) { +func testAccV2Client(t *testing.T) *v2client.Client { + c, err := v2client.NewClient() + if err != nil { + t.Fatalf("could not initialize Honeycomb client: %v", err) + } + return c +} + +// TestAcc_Configuration_ClientOptions verifies that the provider can be +// configured with the three supported API client permutations. +// 1. v1 API Client (API Key only) +// 2. v2 API Client (API Key ID/Secret) +// 3. v1 and v2 API Clients (API Key and API Key ID/Secret) +func TestAcc_Configuration_ClientOptions(t *testing.T) { +} + +// TestAcc_Configuration_MuxServer verifies that a V5 Mux Server can be properly +// created while the Plugin SDK and the Plugin Framework are both in use in +// the provider. +func TestAcc_Configuration_MuxServer(t *testing.T) { resource.Test(t, resource.TestCase{ ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, Steps: []resource.TestStep{ diff --git a/internal/provider/query_resource.go b/internal/provider/query_resource.go index d861f3bc..ff849225 100644 --- a/internal/provider/query_resource.go +++ b/internal/provider/query_resource.go @@ -15,6 +15,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types" "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/modifiers" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation" @@ -41,7 +42,17 @@ func (*queryResource) Metadata(_ context.Context, req resource.MetadataRequest, } func (r *queryResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { - r.client = getClientFromResourceRequest(&req) + w := getClientFromResourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + r.client = c } func (*queryResource) Schema(_ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { @@ -131,7 +142,7 @@ func (r *queryResource) Read(ctx context.Context, req resource.ReadRequest, resp return } - var detailedErr client.DetailedError + var detailedErr hnyerr.DetailedError query, err := r.client.Queries.Get(ctx, state.Dataset.ValueString(), state.ID.ValueString()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { diff --git a/internal/provider/query_specification_data_source.go b/internal/provider/query_specification_data_source.go index 31bfb495..46eddb0a 100644 --- a/internal/provider/query_specification_data_source.go +++ b/internal/provider/query_specification_data_source.go @@ -200,7 +200,17 @@ func (d *querySpecDataSource) Schema(_ context.Context, _ datasource.SchemaReque } func (d *querySpecDataSource) Configure(ctx context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { - d.client = getClientFromDatasourceRequest(&req) + w := getClientFromDatasourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + d.client = c } func (d *querySpecDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { diff --git a/internal/provider/slo_data_source.go b/internal/provider/slo_data_source.go index e49f692e..94587fb0 100644 --- a/internal/provider/slo_data_source.go +++ b/internal/provider/slo_data_source.go @@ -87,8 +87,18 @@ func (d *sloDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, re } } -func (d *sloDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { - d.client = getClientFromDatasourceRequest(&req) +func (d *sloDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + w := getClientFromDatasourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + d.client = c } func (d *sloDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { diff --git a/internal/provider/slos_data_source.go b/internal/provider/slos_data_source.go index 7987907b..056c569c 100644 --- a/internal/provider/slos_data_source.go +++ b/internal/provider/slos_data_source.go @@ -109,8 +109,18 @@ func (d *slosDataSource) Schema(_ context.Context, _ datasource.SchemaRequest, r } } -func (d *slosDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, _ *datasource.ConfigureResponse) { - d.client = getClientFromDatasourceRequest(&req) +func (d *slosDataSource) Configure(_ context.Context, req datasource.ConfigureRequest, resp *datasource.ConfigureResponse) { + w := getClientFromDatasourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + d.client = c } func (d *slosDataSource) Read(ctx context.Context, req datasource.ReadRequest, resp *datasource.ReadResponse) { diff --git a/internal/provider/trigger_resource.go b/internal/provider/trigger_resource.go index 2ad4d6cf..18e597fa 100644 --- a/internal/provider/trigger_resource.go +++ b/internal/provider/trigger_resource.go @@ -24,6 +24,7 @@ import ( "github.com/hashicorp/terraform-plugin-framework/types/basetypes" "github.com/honeycombio/terraform-provider-honeycombio/client" + hnyerr "github.com/honeycombio/terraform-provider-honeycombio/client/errors" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/modifiers" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/validation" @@ -214,8 +215,18 @@ func (r *triggerResource) Schema(_ context.Context, _ resource.SchemaRequest, re } } -func (r *triggerResource) Configure(_ context.Context, req resource.ConfigureRequest, _ *resource.ConfigureResponse) { - r.client = getClientFromResourceRequest(&req) +func (r *triggerResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + w := getClientFromResourceRequest(&req) + if w == nil { + return + } + + c, err := w.V1Client() + if err != nil || c == nil { + resp.Diagnostics.AddError("Failed to configure client", err.Error()) + return + } + r.client = c } func (r *triggerResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { @@ -305,7 +316,7 @@ func (r *triggerResource) Read(ctx context.Context, req resource.ReadRequest, re return } - var detailedErr client.DetailedError + var detailedErr hnyerr.DetailedError trigger, err := r.client.Triggers.Get(ctx, state.Dataset.ValueString(), state.ID.ValueString()) if errors.As(err, &detailedErr) { if detailedErr.IsNotFound() { @@ -455,7 +466,7 @@ func (r *triggerResource) Delete(ctx context.Context, req resource.DeleteRequest return } - var detailedErr client.DetailedError + var detailedErr hnyerr.DetailedError err := r.client.Triggers.Delete(ctx, state.Dataset.ValueString(), state.ID.ValueString()) if err != nil { if errors.As(err, &detailedErr) {