diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 8aaeff7f..9df1cb3e 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -61,7 +61,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 run: go test -v -coverprofile=client-coverage.txt -covermode=atomic ./client/... - uses: hashicorp/setup-terraform@v3 @@ -73,7 +76,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,7 +118,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 run: go test -v -coverprofile=client-coverage.txt -covermode=atomic ./client/... - uses: hashicorp/setup-terraform@v3 @@ -125,7 +134,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/client.go b/client/client.go index 89970901..021b1b60 100644 --- a/client/client.go +++ b/client/client.go @@ -221,7 +221,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 ErrorFromResponse(resp) } if responseBody != nil { err = json.NewDecoder(resp.Body).Decode(responseBody) diff --git a/client/errors.go b/client/errors.go index 76280311..cb77a9d4 100644 --- a/client/errors.go +++ b/client/errors.go @@ -2,9 +2,10 @@ package client import ( "encoding/json" - "fmt" - "io" + "errors" "net/http" + + "github.com/hashicorp/jsonapi" ) // DetailedError is an RFC7807 'Problem Detail' formatted error message. @@ -52,6 +53,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 +69,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 +82,58 @@ 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 ErrorFromResponse(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 + } + } + detailedError.Details = details + } + 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_test.go index bf3240ce..f5557dbf 100644 --- a/client/errors_test.go +++ b/client/errors_test.go @@ -1,11 +1,14 @@ package client_test import ( + "bytes" "context" "fmt" "net/http" + "net/http/httptest" "testing" + "github.com/hashicorp/jsonapi" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -191,3 +194,83 @@ func TestErrors_ErrorTypeDetail_String(t *testing.T) { }) } } + +func TestErrors_JSONAPI(t *testing.T) { + t.Parallel() + + testCases := []struct { + name string + code int + body jsonapi.ErrorsPayload + expectedOutput client.DetailedError + }{ + { + name: "single error", + code: http.StatusConflict, + body: jsonapi.ErrorsPayload{ + Errors: []*jsonapi.ErrorObject{ + { + Status: "409", + Title: "Conflict", + Detail: "The resource already exists.", + Code: "/errors/conflict", + }, + }, + }, + expectedOutput: client.DetailedError{ + Status: 409, + Type: "/errors/conflict", + Message: "The resource already exists.", + Title: "Conflict", + }, + }, + { + name: "multi error", + code: http.StatusUnprocessableEntity, + body: jsonapi.ErrorsPayload{ + Errors: []*jsonapi.ErrorObject{ + { + Status: "422", + Code: "/errors/validation-failed", + Title: "The provided input is invalid.", + }, + { + Status: "422", + Code: "/errors/validation-failed", + Title: "The provided input is invalid.", + }, + }, + }, + expectedOutput: client.DetailedError{ + Status: 422, + Title: "The provided input is invalid.", + Details: []client.ErrorTypeDetail{ + { + Code: "/errors/validation-failed", + }, + { + Code: "/errors/validation-failed", + }, + }, + }, + }, + } + + for _, testCase := range testCases { + t.Run(testCase.name, func(t *testing.T) { + resp := &httptest.ResponseRecorder{ + Code: testCase.code, + HeaderMap: http.Header{ + "Content-Type": []string{jsonapi.MediaType}, + }, + } + + buf := bytes.NewBuffer(nil) + jsonapi.MarshalErrors(buf, testCase.body.Errors) + resp.Body = bytes.NewBuffer(buf.Bytes()) + + actualOutput := client.ErrorFromResponse(resp.Result()) + assert.Equal(t, testCase.expectedOutput, actualOutput) + }) + } +} diff --git a/client/v2/api_keys.go b/client/v2/api_keys.go new file mode 100644 index 00000000..58d4712c --- /dev/null +++ b/client/v2/api_keys.go @@ -0,0 +1,115 @@ +package v2 + +import ( + "context" + "fmt" + "net/http" + + "github.com/hashicorp/jsonapi" + + hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client" +) + +// 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, hnyclient.ErrorFromResponse(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, hnyclient.ErrorFromResponse(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, hnyclient.ErrorFromResponse(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 hnyclient.ErrorFromResponse(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..57710344 --- /dev/null +++ b/client/v2/api_keys_test.go @@ -0,0 +1,132 @@ +package v2 + +import ( + "context" + "fmt" + "math" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client" + "github.com/honeycombio/terraform-provider-honeycombio/internal/helper" +) + +func TestClient_APIKeys(t *testing.T) { + ctx := context.Background() + c := newTestClient(t) + // TODO: use the environments API + testEnvironmentID := os.Getenv("HONEYCOMB_ENVIRONMENT_ID") + + // create a new key + k, err := c.APIKeys.Create(ctx, &APIKey{ + Name: helper.ToPtr("test key"), + KeyType: "ingest", + Environment: &Environment{ + ID: testEnvironmentID, + }, + 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 hnyclient.DetailedError + require.ErrorAs(t, err, &de) + assert.True(t, de.IsNotFound()) +} + +func TestClient_APIKeys_Pagination(t *testing.T) { + ctx := context.Background() + c := newTestClient(t) + // TODO: use the environments API + testEnvironmentID := os.Getenv("HONEYCOMB_ENVIRONMENT_ID") + + // 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: testEnvironmentID, + }, + }) + 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..a2c0116c --- /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" + + hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client" +) + +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, hnyclient.ErrorFromResponse(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..6ccfb75d --- /dev/null +++ b/client/v2/client_test.go @@ -0,0 +1,138 @@ +package v2 + +import ( + "context" + "net/http" + "testing" + + "github.com/joho/godotenv" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client" +) + +const ( + testEnvFilePath = "../../.env" + testUserAgent = "go-honeycombio/test" +) + +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 hnyclient.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..6df9decb --- /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 { + 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..312cde47 --- /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" + + hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client" +) + +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, hnyclient.ErrorFromResponse(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/docs/index.md b/docs/index.md index 8f48ee41..acee900d 100644 --- a/docs/index.md +++ b/docs/index.md @@ -21,7 +21,8 @@ terraform { # Configure the Honeycomb provider provider "honeycombio" { - # You can set the API key with the environment variable HONEYCOMB_API_KEY + # You can set the API key with the environment variable HONEYCOMB_API_KEY, + # or the HONEYCOMB_KEY_ID+HONEYCOMB_KEY_SECRET environment variable pair } variable "dataset" { @@ -51,18 +52,41 @@ provider "honeycombio" { ## Authentication -The Honeycomb provider requires an API key to communicate with the Honeycomb API. API keys and their permissions can be managed in _Team settings_. +The Honeycomb provider requires an API key to communicate with the Honeycomb APIs. +The provider can make calls to v1 and v2 APIs and requires specific key configurations for each. +For more information about API Keys, check out [Best Practices for API Keys](https://docs.honeycomb.io/get-started/best-practices/api-keys/). + +A single instance of the provider can be configured with both key types. +At least one of the v1 or v2 API key configuration is required. + +### v1 APIs + +v1 APIs require Configuration Keys. +Their permissions can be managed in _Environment settings_. +Most resources and data sources call v1 APIs today. The key can be set with the `api_key` argument or via the `HONEYCOMB_API_KEY` or `HONEYCOMBIO_APIKEY` environment variable. `HONEYCOMB_API_KEY` environment variable will take priority over the `HONEYCOMBIO_APIKEY` environment variable. +### v2 APIs + +v2 APIs require a Mangement Key. +Their permissions can be managed in _Team settings_. +Resources and data sources that call v2 APIs will be noted along with the scope required to use the resource or data source. + +The key pair can be set with the `api_key_id` and `api_key_secret` arguments, or via the `HONEYCOMB_KEY_ID` and `HONEYCOMB_KEY_SECRET` environment variables. + ~> **Note** Hard-coding API keys in any Terraform configuration is not recommended. Consider using the one of the environment variable options. ## Argument Reference Arguments accepted by this provider include: -* `api_key` - (Required) The Honeycomb API key to use. It can also be set using `HONEYCOMB_API_KEY` or `HONEYCOMBIO_APIKEY` environment variables. +* `api_key` - (Optional) The Honeycomb API key to use. It can also be set using `HONEYCOMB_API_KEY` or `HONEYCOMBIO_APIKEY` environment variables. +* `api_key_id` - (Optional) The ID portion of the Honeycomb Management API key to use. It can also be set via the `HONEYCOMB_KEY_ID` environment variable. +* `api_key_secret` - (Optional) The secret portion of the Honeycomb Management API key to use. It can also be set via the `HONEYCOMB_KEY_SECRET` environment variable. * `api_url` - (Optional) Override the URL of the Honeycomb.io API. It can also be set using `HONEYCOMB_API_ENDPOINT`. Defaults to `https://api.honeycomb.io`. * `debug` - (Optional) Enable to log additional debug information. To view the logs, set `TF_LOG` to at least debug. + +At least one of `api_key`, or the `api_key_id` and `api_key_secret` pair must be configured. diff --git a/docs/resources/api_key.md b/docs/resources/api_key.md new file mode 100644 index 00000000..d1933f5f --- /dev/null +++ b/docs/resources/api_key.md @@ -0,0 +1,50 @@ +# Resource: honeycombio_api_key + +Creates a Honeycomb API Key. +For more information about API Keys, check out [Best Practices for API Keys](https://docs.honeycomb.io/get-started/best-practices/api-keys/). + +-> **NOTE** This resource requires the provider be configured with a Management Key with `api-keys:write` in the configured scopes. + +## Example Usage + +```hcl +resource "honeycombio_api_key" "prod_ingest" { + name = "Production Ingest" + type = "ingest" + + environment_id = var.environment_id + + permissions { + create_datasets = true + } +} + +output "ingest_key" { + value = "${honeycomb_api_key.prod_ingest.id}${honeycomb_api_key.prod_ingest.secret}" +} +``` + +## Argument Reference + +The following arguments are supported: + +* `name` - (Required) The name of the API key. +* `type` - (Required) The type of API key. Currently only `ingest` is supported. +* `environment_id` - (Required) The Environment ID the API key is scoped to. +* `disabled` - (Optional) Whether the API key is disabled. Defaults to `false`. +* `permissions` - (Optional) A configuration block (described below) setting what actions the API key can perform. + +Each API key configuration may contain a single `permissions` block, which accepts the following arguments: + +* `create_datasets` - (Optional) Allow this key to create missing datasets when sending telemetry. Defaults to `false`. + +## Attribute Reference + +In addition to all arguments above, the following attributes are exported: + +* `id` - The ID of the API Key. +* `secret` - The secret portion of the API Key. + +## Import + +API Keys cannot be imported. 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/data_source_column.go b/honeycombio/data_source_column.go index 4f8be430..17f90505 100644 --- a/honeycombio/data_source_column.go +++ b/honeycombio/data_source_column.go @@ -6,8 +6,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - - honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" ) func dataSourceHoneycombioColumn() *schema.Resource { @@ -79,7 +77,10 @@ func dataSourceHoneycombioColumn() *schema.Resource { } func dataSourceHoneycombioColumnRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) matchName := d.Get("name").(string) diff --git a/honeycombio/data_source_columns.go b/honeycombio/data_source_columns.go index 43af2688..43a4a8bc 100644 --- a/honeycombio/data_source_columns.go +++ b/honeycombio/data_source_columns.go @@ -8,7 +8,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/hashcode" ) @@ -40,7 +39,10 @@ func dataSourceHoneycombioColumns() *schema.Resource { } func dataSourceHoneycombioColumnsRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) diff --git a/honeycombio/data_source_datasets.go b/honeycombio/data_source_datasets.go index 8a08dca4..56688596 100644 --- a/honeycombio/data_source_datasets.go +++ b/honeycombio/data_source_datasets.go @@ -8,7 +8,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" - honeycombio "github.com/honeycombio/terraform-provider-honeycombio/client" "github.com/honeycombio/terraform-provider-honeycombio/internal/helper/hashcode" ) @@ -40,7 +39,10 @@ func dataSourceHoneycombioDatasets() *schema.Resource { } func dataSourceHoneycombioDatasetsRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } datasets, err := client.Datasets.List(ctx) if err != nil { diff --git a/honeycombio/data_source_query_result.go b/honeycombio/data_source_query_result.go index 7d2b4244..93937dc9 100644 --- a/honeycombio/data_source_query_result.go +++ b/honeycombio/data_source_query_result.go @@ -53,10 +53,13 @@ func dataSourceHoneycombioQueryResult() *schema.Resource { func dataSourceHoneycombioQueryResultRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { var querySpec honeycombio.QuerySpec - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) - err := json.Unmarshal([]byte(d.Get("query_json").(string)), &querySpec) + err = json.Unmarshal([]byte(d.Get("query_json").(string)), &querySpec) if err != nil { return diag.FromErr(err) } diff --git a/honeycombio/data_source_recipient.go b/honeycombio/data_source_recipient.go index a6bd0f42..cf6a8c30 100644 --- a/honeycombio/data_source_recipient.go +++ b/honeycombio/data_source_recipient.go @@ -134,7 +134,10 @@ If you want to match multiple recipients, use the 'honeycombio_recipients' data } func dataSourceHoneycombioRecipientRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } rcpts, err := client.Recipients.List(ctx) if err != nil { diff --git a/honeycombio/data_source_recipients.go b/honeycombio/data_source_recipients.go index 47b79983..e475e610 100644 --- a/honeycombio/data_source_recipients.go +++ b/honeycombio/data_source_recipients.go @@ -73,7 +73,10 @@ func dataSourceHoneycombioRecipients() *schema.Resource { } func dataSourceHoneycombioRecipientsRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } rcpts, err := client.Recipients.List(ctx) if err != nil { diff --git a/honeycombio/data_source_trigger_recipient.go b/honeycombio/data_source_trigger_recipient.go index e06eeecb..4661d793 100644 --- a/honeycombio/data_source_trigger_recipient.go +++ b/honeycombio/data_source_trigger_recipient.go @@ -35,7 +35,10 @@ func dataSourceHoneycombioSlackRecipient() *schema.Resource { } func dataSourceHoneycombioSlackRecipientRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) triggers, err := client.Triggers.List(ctx, dataset) diff --git a/honeycombio/provider.go b/honeycombio/provider.go index 5fe5c5d5..bfa89d51 100644 --- a/honeycombio/provider.go +++ b/honeycombio/provider.go @@ -2,6 +2,7 @@ package honeycombio import ( "context" + "errors" "os" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" @@ -26,6 +27,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.", @@ -79,28 +92,34 @@ func Provider(version string) *schema.Provider { debug = v.(bool) } - // API Key cannot be determined - if apiKey == "" { - return nil, diag.Errorf( - "Unknown Honeycomb API Key.\n\n" + - "The provider cannot create the Honeycomb client as there is an unknown configuration value for the Honeycomb API Key. " + - "Either target apply the source of the value first, set the value statically in the configuration, or use the HONEYCOMB_API_KEY environment variable.", - ) - } - - config := &honeycombio.Config{ - APIKey: apiKey, - APIUrl: d.Get("api_url").(string), - UserAgent: provider.UserAgent("terraform-provider-honeycombio", version), - Debug: debug, - } - c, err := honeycombio.NewClientWithConfig(config) - if err != nil { - return nil, diag.FromErr(err) + // if the API key is set, use it to create the client + // we now rely on the Framework version of the provider to validate the configuration + if apiKey != "" { + config := &honeycombio.Config{ + APIKey: apiKey, + APIUrl: d.Get("api_url").(string), + UserAgent: provider.UserAgent("terraform-provider-honeycombio", version), + Debug: debug, + } + c, err := honeycombio.NewClientWithConfig(config) + if err != nil { + return nil, diag.FromErr(err) + } + return c, nil } - return c, nil + return nil, nil } return provider } + +func getConfiguredClient(meta any) (*honeycombio.Client, error) { + client, ok := meta.(*honeycombio.Client) + if !ok || client == nil { + return nil, errors.New("No v1 API client configured for this provider. " + + "Set the `api_key` attribute in the provider's configuration, " + + "or set the HONEYCOMB_API_KEY environment variable.") + } + return client, nil +} diff --git a/honeycombio/resource_board.go b/honeycombio/resource_board.go index 0619a60d..8677ce25 100644 --- a/honeycombio/resource_board.go +++ b/honeycombio/resource_board.go @@ -164,7 +164,10 @@ See [Graph Settings](https://docs.honeycomb.io/working-with-your-data/graph-sett } func resourceBoardCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } b, err := expandBoard(d) if err != nil { @@ -181,7 +184,10 @@ 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) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } var detailedErr honeycombio.DetailedError b, err := client.Boards.Get(ctx, d.Id()) @@ -229,7 +235,10 @@ func resourceBoardRead(ctx context.Context, d *schema.ResourceData, meta interfa } func resourceBoardUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } b, err := expandBoard(d) if err != nil { @@ -246,9 +255,12 @@ func resourceBoardUpdate(ctx context.Context, d *schema.ResourceData, meta inter } func resourceBoardDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } - err := client.Boards.Delete(ctx, d.Id()) + err = client.Boards.Delete(ctx, d.Id()) if err != nil { return diagFromErr(err) } diff --git a/honeycombio/resource_column.go b/honeycombio/resource_column.go index ac37ba4b..03672cf0 100644 --- a/honeycombio/resource_column.go +++ b/honeycombio/resource_column.go @@ -98,7 +98,10 @@ func resourceColumnImport(ctx context.Context, d *schema.ResourceData, i interfa } func resourceColumnCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) @@ -112,7 +115,10 @@ func resourceColumnCreate(ctx context.Context, d *schema.ResourceData, meta inte } func resourceColumnRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) // if name is not set, try to get key_name. @@ -149,7 +155,10 @@ func resourceColumnRead(ctx context.Context, d *schema.ResourceData, meta interf } func resourceColumnUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) @@ -163,11 +172,14 @@ func resourceColumnUpdate(ctx context.Context, d *schema.ResourceData, meta inte } func resourceColumnDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) - err := client.Columns.Delete(ctx, dataset, d.Id()) + err = client.Columns.Delete(ctx, dataset, d.Id()) if err != nil { return diagFromErr(err) } diff --git a/honeycombio/resource_dataset.go b/honeycombio/resource_dataset.go index 59a467cd..c3d85f8e 100644 --- a/honeycombio/resource_dataset.go +++ b/honeycombio/resource_dataset.go @@ -60,7 +60,10 @@ func newDataset() *schema.Resource { } func resourceDatasetCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } data := &honeycombio.Dataset{ Name: d.Get("name").(string), @@ -77,7 +80,10 @@ 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) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } var detailedErr honeycombio.DetailedError dataset, err := client.Datasets.Get(ctx, d.Id()) @@ -103,7 +109,10 @@ func resourceDatasetRead(ctx context.Context, d *schema.ResourceData, meta inter } func resourceDatasetUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } data := &honeycombio.Dataset{ Name: d.Get("name").(string), diff --git a/honeycombio/resource_dataset_definition.go b/honeycombio/resource_dataset_definition.go index 2988ebb5..c2f0c5fa 100644 --- a/honeycombio/resource_dataset_definition.go +++ b/honeycombio/resource_dataset_definition.go @@ -53,7 +53,10 @@ func newDatasetDefinition() *schema.Resource { } func resourceDatasetDefinitionRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) @@ -82,14 +85,17 @@ func resourceDatasetDefinitionRead(ctx context.Context, d *schema.ResourceData, } func resourceDatasetDefinitionUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) name := d.Get("name").(string) value := d.Get("column").(string) dd := expandDatasetDefinition(name, value) - _, err := client.DatasetDefinitions.Update(ctx, dataset, dd) + _, err = client.DatasetDefinitions.Update(ctx, dataset, dd) if err != nil { return diagFromErr(err) } @@ -98,14 +104,17 @@ func resourceDatasetDefinitionUpdate(ctx context.Context, d *schema.ResourceData } func resourceDatasetDefinitionDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) name := d.Get("name").(string) // 'deleting' a definition is really resetting it dd := expandDatasetDefinition(name, "") - _, err := client.DatasetDefinitions.Update(ctx, dataset, dd) + _, err = client.DatasetDefinitions.Update(ctx, dataset, dd) if err != nil { return diagFromErr(err) } diff --git a/honeycombio/resource_derived_column.go b/honeycombio/resource_derived_column.go index e9449206..f857dbed 100644 --- a/honeycombio/resource_derived_column.go +++ b/honeycombio/resource_derived_column.go @@ -61,12 +61,15 @@ func resourceDerivedColumnImport(ctx context.Context, d *schema.ResourceData, i } func resourceDerivedColumnCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) derivedColumn := readDerivedColumn(d) - derivedColumn, err := client.DerivedColumns.Create(ctx, dataset, derivedColumn) + derivedColumn, err = client.DerivedColumns.Create(ctx, dataset, derivedColumn) if err != nil { return diagFromErr(err) } @@ -76,7 +79,10 @@ func resourceDerivedColumnCreate(ctx context.Context, d *schema.ResourceData, me } func resourceDerivedColumnRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) @@ -101,12 +107,15 @@ func resourceDerivedColumnRead(ctx context.Context, d *schema.ResourceData, meta } func resourceDerivedColumnUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) derivedColumn := readDerivedColumn(d) - derivedColumn, err := client.DerivedColumns.Update(ctx, dataset, derivedColumn) + derivedColumn, err = client.DerivedColumns.Update(ctx, dataset, derivedColumn) if err != nil { return diagFromErr(err) } @@ -116,11 +125,14 @@ func resourceDerivedColumnUpdate(ctx context.Context, d *schema.ResourceData, me } func resourceDerivedColumnDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) - err := client.DerivedColumns.Delete(ctx, dataset, d.Id()) + err = client.DerivedColumns.Delete(ctx, dataset, d.Id()) if err != nil { return diagFromErr(err) } diff --git a/honeycombio/resource_marker.go b/honeycombio/resource_marker.go index a4ff4c25..7760c8fc 100644 --- a/honeycombio/resource_marker.go +++ b/honeycombio/resource_marker.go @@ -43,7 +43,10 @@ func newMarker() *schema.Resource { } func resourceMarkerCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) @@ -62,7 +65,10 @@ 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) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } var detailedErr honeycombio.DetailedError marker, err := client.Markers.Get(ctx, d.Get("dataset").(string), d.Id()) diff --git a/honeycombio/resource_marker_setting.go b/honeycombio/resource_marker_setting.go index 3530cce8..21539f8e 100644 --- a/honeycombio/resource_marker_setting.go +++ b/honeycombio/resource_marker_setting.go @@ -52,7 +52,10 @@ func newMarkerSetting() *schema.Resource { } func resourceMarkerSettingCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) @@ -72,7 +75,10 @@ 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) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } var detailedErr honeycombio.DetailedError markerSetting, err := client.MarkerSettings.Get(ctx, d.Get("dataset").(string), d.Id()) @@ -96,7 +102,10 @@ func resourceMarkerSettingRead(ctx context.Context, d *schema.ResourceData, meta } func resourceMarkerSettingUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) markerType := d.Get("type").(string) @@ -116,11 +125,14 @@ func resourceMarkerSettingUpdate(ctx context.Context, d *schema.ResourceData, me } func resourceMarkerSettingDelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) - err := client.MarkerSettings.Delete(ctx, dataset, d.Id()) + err = client.MarkerSettings.Delete(ctx, dataset, d.Id()) if err != nil { return diagFromErr(err) } diff --git a/honeycombio/resource_query_annotation.go b/honeycombio/resource_query_annotation.go index c819a06f..d391ab68 100644 --- a/honeycombio/resource_query_annotation.go +++ b/honeycombio/resource_query_annotation.go @@ -54,11 +54,14 @@ func buildQueryAnnotation(d *schema.ResourceData) *honeycombio.QueryAnnotation { } func resourceQueryAnnotationCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) queryAnnotation := buildQueryAnnotation(d) - queryAnnotation, err := client.QueryAnnotations.Create(ctx, dataset, queryAnnotation) + queryAnnotation, err = client.QueryAnnotations.Create(ctx, dataset, queryAnnotation) if err != nil { return diagFromErr(err) } @@ -68,11 +71,14 @@ func resourceQueryAnnotationCreate(ctx context.Context, d *schema.ResourceData, } func resourceQueryAnnotationUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) queryAnnotation := buildQueryAnnotation(d) - _, err := client.QueryAnnotations.Update(ctx, dataset, queryAnnotation) + _, err = client.QueryAnnotations.Update(ctx, dataset, queryAnnotation) if err != nil { return diagFromErr(err) } @@ -81,10 +87,13 @@ func resourceQueryAnnotationUpdate(ctx context.Context, d *schema.ResourceData, } func resourceQueryAnnotationDestroy(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) - err := client.QueryAnnotations.Delete(ctx, dataset, d.Id()) + err = client.QueryAnnotations.Delete(ctx, dataset, d.Id()) if err != nil { return diagFromErr(err) } @@ -92,7 +101,10 @@ func resourceQueryAnnotationDestroy(ctx context.Context, d *schema.ResourceData, } func resourceQueryAnnotationRead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) var detailedErr honeycombio.DetailedError diff --git a/honeycombio/resource_slo.go b/honeycombio/resource_slo.go index a037990e..eb5563a4 100644 --- a/honeycombio/resource_slo.go +++ b/honeycombio/resource_slo.go @@ -80,7 +80,10 @@ func resourceSLOImport(ctx context.Context, d *schema.ResourceData, i interface{ } func resourceSLOCreate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) s, err := expandSLO(d) @@ -98,7 +101,10 @@ func resourceSLOCreate(ctx context.Context, d *schema.ResourceData, meta interfa } func resourceSLORead(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) @@ -126,7 +132,10 @@ func resourceSLORead(ctx context.Context, d *schema.ResourceData, meta interface } func resourceSLOUpdate(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) s, err := expandSLO(d) @@ -144,11 +153,14 @@ func resourceSLOUpdate(ctx context.Context, d *schema.ResourceData, meta interfa } func resourceSLODelete(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } dataset := d.Get("dataset").(string) - err := client.SLOs.Delete(ctx, dataset, d.Id()) + err = client.SLOs.Delete(ctx, dataset, d.Id()) if err != nil { return diag.FromErr(err) } diff --git a/honeycombio/type_helpers.go b/honeycombio/type_helpers.go index 5fdf2a31..821f87da 100644 --- a/honeycombio/type_helpers.go +++ b/honeycombio/type_helpers.go @@ -57,7 +57,10 @@ func expandRecipient(t honeycombio.RecipientType, d *schema.ResourceData) (*hone } func createRecipient(ctx context.Context, d *schema.ResourceData, meta interface{}, t honeycombio.RecipientType) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } r, err := expandRecipient(t, d) if err != nil { @@ -74,7 +77,10 @@ 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) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } var detailedErr honeycombio.DetailedError r, err := client.Recipients.Get(ctx, d.Id()) @@ -113,7 +119,10 @@ func readRecipient(ctx context.Context, d *schema.ResourceData, meta interface{} } func updateRecipient(ctx context.Context, d *schema.ResourceData, meta interface{}, t honeycombio.RecipientType) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } r, err := expandRecipient(t, d) if err != nil { @@ -130,9 +139,12 @@ func updateRecipient(ctx context.Context, d *schema.ResourceData, meta interface } func deleteRecipient(ctx context.Context, d *schema.ResourceData, meta interface{}) diag.Diagnostics { - client := meta.(*honeycombio.Client) + client, err := getConfiguredClient(meta) + if err != nil { + return diagFromErr(err) + } - err := client.Recipients.Delete(ctx, d.Id()) + err = client.Recipients.Delete(ctx, d.Id()) if err != nil { return diagFromErr(err) } diff --git a/internal/helper/diag.go b/internal/helper/diag.go index 65d9c311..55666b62 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" + hnyclient "github.com/honeycombio/terraform-provider-honeycombio/client" ) // DetailedErrorDiagnostic is a Diagnostic which nicely wraps a client.DetailedError type DetailedErrorDiagnostic struct { summary string - e *client.DetailedError + e *hnyclient.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 *hnyclient.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 *hnyclient.DetailedError if errors.As(err, &detailedErr) { diag.Append(DetailedErrorDiagnostic{ summary: "Error " + summary, diff --git a/internal/helper/validation/divisible_by_test.go b/internal/helper/validation/divisible_by_test.go index 9b6c44e4..b646b64b 100644 --- a/internal/helper/validation/divisible_by_test.go +++ b/internal/helper/validation/divisible_by_test.go @@ -53,7 +53,7 @@ func Test_BetweenValidator(t *testing.T) { ConfigValue: test.val, } response := validator.Int64Response{} - validation.Int64DivisibleBy(test.divisor).ValidateInt64(context.TODO(), request, &response) + validation.Int64DivisibleBy(test.divisor).ValidateInt64(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/internal/helper/validation/precision_at_most_test.go b/internal/helper/validation/precision_at_most_test.go index db87f56d..60e7fb91 100644 --- a/internal/helper/validation/precision_at_most_test.go +++ b/internal/helper/validation/precision_at_most_test.go @@ -63,7 +63,7 @@ func TestValidation_PrecisionAtMost(t *testing.T) { ConfigValue: test.value, } response := validator.Float64Response{} - validation.Float64PrecisionAtMost(test.maxPrecision).ValidateFloat64(context.TODO(), request, &response) + validation.Float64PrecisionAtMost(test.maxPrecision).ValidateFloat64(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/internal/helper/validation/query_spec_test.go b/internal/helper/validation/query_spec_test.go index 3720b42b..50916824 100644 --- a/internal/helper/validation/query_spec_test.go +++ b/internal/helper/validation/query_spec_test.go @@ -47,7 +47,7 @@ func Test_QuerySpecValidator(t *testing.T) { ConfigValue: test.val, } response := validator.StringResponse{} - validation.ValidQuerySpec().ValidateString(context.TODO(), request, &response) + validation.ValidQuerySpec().ValidateString(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") diff --git a/internal/helper/validation/valid_regex_test.go b/internal/helper/validation/valid_regex_test.go index e0b49b92..39bf36e0 100644 --- a/internal/helper/validation/valid_regex_test.go +++ b/internal/helper/validation/valid_regex_test.go @@ -44,7 +44,7 @@ func Test_IsValidRegexValidator(t *testing.T) { ConfigValue: test.val, } response := validator.StringResponse{} - validation.IsValidRegExp().ValidateString(context.TODO(), request, &response) + validation.IsValidRegExp().ValidateString(context.Background(), request, &response) if !response.Diagnostics.HasError() && test.expectError { t.Fatal("expected error, got no error") 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..cb04eb74 --- /dev/null +++ b/internal/provider/api_key_resource.go @@ -0,0 +1,316 @@ +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/listplanmodifier" + "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" + + "github.com/honeycombio/terraform-provider-honeycombio/client" + hny "github.com/honeycombio/terraform-provider-honeycombio/client" + 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 implementing 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), + }, + PlanModifiers: []planmodifier.List{ + listplanmodifier.RequiresReplace(), + }, + 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 hny.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 client.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..661d218e 100644 --- a/internal/provider/burn_alert_resource.go +++ b/internal/provider/burn_alert_resource.go @@ -45,7 +45,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) { 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..417d0c0a 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, } } @@ -98,6 +113,25 @@ func (p *HoneycombioProvider) Configure(ctx context.Context, req provider.Config "Either target apply the source of the value first, set the value statically in the configuration, or use the HONEYCOMB_API_KEY environment variable.", ) } + if config.KeyID.IsUnknown() { + resp.Diagnostics.AddAttributeError( + path.Root("api_key_id"), + "Unknown Honeycomb API Key ID", + "The provider cannot create the Honeycomb client as there is an unknown configuration value for the Honeycomb API Key ID. "+ + "Either target apply the source of the value first, set the value statically in the configuration, or use the HONEYCOMB_KEY_ID environment variable.", + ) + } + if config.KeySecret.IsUnknown() { + resp.Diagnostics.AddAttributeError( + path.Root("api_key_secret"), + "Unknown Honeycomb API Key Secret", + "The provider cannot create the Honeycomb client as there is an unknown configuration value for the Honeycomb API Key Secret. "+ + "Either target apply the source of the value first, set the value statically in the configuration, or use the HONEYCOMB_KEY_SECRET environment variable.", + ) + } + if resp.Diagnostics.HasError() { + return + } apiKey := os.Getenv(client.DefaultAPIKeyEnv) if apiKey == "" { @@ -105,18 +139,45 @@ func (p *HoneycombioProvider) Configure(ctx context.Context, req provider.Config //nolint:staticcheck apiKey = os.Getenv(client.LegacyAPIKeyEnv) } + keyID := os.Getenv(v2client.DefaultAPIKeyIDEnv) + keySecret := os.Getenv(v2client.DefaultAPIKeySecretEnv) + if !config.APIKey.IsNull() { apiKey = config.APIKey.ValueString() } - if apiKey == "" { - resp.Diagnostics.AddAttributeError( - path.Root("api_key"), - "Missing Honeycomb API Key", - "The provider cannot create the Honeycomb API client as there is a missing or empty value for the Honeycomb API Key. "+ - "Set the value in the configuration or use the HONEYCOMB_API_KEY environment variable. "+ - "If either is already set, ensure the value is not empty.", + if !config.KeyID.IsNull() { + keyID = config.KeyID.ValueString() + } + if !config.KeySecret.IsNull() { + keySecret = config.KeySecret.ValueString() + } + + initv1Client, initv2Client := false, false + if apiKey != "" { + initv1Client = true + } + if keyID != "" && keySecret != "" { + initv2Client = true + } else if (keyID != "" && keySecret == "") || (keyID == "" && keySecret != "") { + resp.Diagnostics.AddError( + "Unable to initialize Honeycomb provider", + "The provider requires both a Honeycomb API Key ID and Secret. "+ + "Set them both in the configuration or via the HONEYCOMB_KEY_ID and HONEYCOMB_KEY_SECRET"+ + "environment variables. "+ + "If you believe both are already set, ensure the values are not empty.", ) + return } + + if !initv1Client && !initv2Client { + resp.Diagnostics.AddError( + "Unable to initialize Honeycomb provider", + "The provider requires at least one of a Honeycomb API Key, or the Honeycomb API Key ID and Secret pair. "+ + "Set either HONEYCOMB_API_KEY for v1 APIs, or HONEYCOMB_KEY_ID and HONEYCOMB_KEY_SECRET for v2 APIs. "+ + "If you believe either is already set, ensure the values are not empty.", + ) + } + if resp.Diagnostics.HasError() { return } @@ -125,38 +186,96 @@ func (p *HoneycombioProvider) Configure(ctx context.Context, req provider.Config if !config.Debug.IsNull() { debug = config.Debug.ValueBool() } + userAgent := fmt.Sprintf( + "Terraform/%s terraform-provider-honeycombio/%s", + req.TerraformVersion, + p.version, + ) - 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), - }) - if err != nil { - resp.Diagnostics.AddError( - "Unable to create Honeycomb API Client", - "An unexpected error occurred when creating the Honeycomb API client.\n\n "+ - "Honeycomb Client Error: "+err.Error(), + cc := &ConfiguredClient{} + if initv1Client { + client, err := client.NewClientWithConfig(&client.Config{ + APIKey: apiKey, + APIUrl: config.APIUrl.ValueString(), + Debug: debug, + UserAgent: userAgent, + }) + if err != nil { + resp.Diagnostics.AddError( + "Unable to create Honeycomb API Client", + "An unexpected error occurred when creating the Honeycomb API client.\n\n "+ + "Honeycomb Client Error: "+err.Error(), + ) + return + } + cc.v1client = client + } + + if initv2Client { + 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 + } + cc.v2client = v2client + } + + resp.DataSourceData = cc + resp.ResourceData = cc +} + +// 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 for this provider. " + + "Set the `api_key` attribute in the provider's configuration, " + + "or set the HONEYCOMB_API_KEY environment variable.", ) - return } + return c.v1client, nil +} - resp.DataSourceData = client - resp.ResourceData = client +func (c *ConfiguredClient) V2Client() (*v2client.Client, error) { + if c.v2client == nil { + return nil, errors.New("No v2 API client configured for this provider. " + + "Set the Key ID and Secret pair in the provider's configuration, " + + "or via the HONEYCOMB_KEY_ID and HONEYCOMB_KEY_SECRET environment variables.", + ) + } + 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..6b62a468 100644 --- a/internal/provider/provider_test.go +++ b/internal/provider/provider_test.go @@ -3,6 +3,7 @@ package provider import ( "context" "os" + "regexp" "testing" "github.com/hashicorp/terraform-plugin-framework/providerserver" @@ -12,6 +13,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 +60,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,16 +91,166 @@ 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) { - resource.Test(t, resource.TestCase{ - ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, - Steps: []resource.TestStep{ - { - // simple smoketest by accessing a datasource - Config: `data "honeycombio_datasets" "all" {}`, +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 verifies that the provider is correctly +// configured with the supported configuration permutations. +func TestAcc_Configuration(t *testing.T) { + // t.Parallel() - not parallelized due to environment variable manipulation + + t.Run("v1 and v2 clients", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + Config: ` +data "honeycombio_datasets" "all" {} + +# TODO: replace with v2-client data source +resource "honeycombio_api_key" "test" { + name = "test" + type = "ingest" + + environment_id = "1" +}`, + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + }) + }) + + t.Run("v1 client only", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + PreConfig: func() { + t.Setenv("HONEYCOMB_KEY_ID", "") + t.Setenv("HONEYCOMB_KEY_SECRET", "") + }, + Config: `data "honeycombio_datasets" "all" {}`, + PlanOnly: true, + }, + }, + }) + }) + + t.Run("v2 client only", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + PreConfig: func() { + t.Setenv("HONEYCOMB_API_KEY", "") + }, + Config: ` +# TODO: replace with v2-client data source +resource "honeycombio_api_key" "test" { + name = "test" + type = "ingest" + + environment_id = "1" +}`, + PlanOnly: true, + ExpectNonEmptyPlan: true, + }, + }, + }) + }) + + t.Run("fails when only half of v2 configuration is set", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + PreConfig: func() { + t.Setenv("HONEYCOMB_API_KEY", "") + t.Setenv("HONEYCOMB_KEY_SECRET", "") + }, + Config: ` +# TODO: replace with v2-client data source +resource "honeycombio_api_key" "test" { + name = "test" + type = "ingest" + + environment_id = "1" +}`, + PlanOnly: true, + ExpectError: regexp.MustCompile(`provider requires both a Honeycomb API Key ID and Secret`), + }, + }, + }) + }) + + t.Run("fails when no clients configured", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + PreConfig: func() { + t.Setenv("HONEYCOMB_API_KEY", "") + t.Setenv("HONEYCOMB_KEY_ID", "") + t.Setenv("HONEYCOMB_KEY_SECRET", "") + }, + Config: `data "honeycombio_datasets" "all" {}`, + PlanOnly: true, + ExpectError: regexp.MustCompile(`requires at least one of a Honeycomb API Key`), + }, + }, + }) + }) + + t.Run("fails v1-only config using v2 resource", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + PreConfig: func() { + t.Setenv("HONEYCOMB_KEY_ID", "") + t.Setenv("HONEYCOMB_KEY_SECRET", "") + }, + Config: ` +# TODO: replace with v2-client data source +resource "honeycombio_api_key" "test" { + name = "test" + type = "ingest" + + environment_id = "1" +}`, + ExpectError: regexp.MustCompile(`No v2 API client configured`), + }, + }, + }) + }) + + t.Run("fails v2-only config using v1 resource", func(t *testing.T) { + resource.Test(t, resource.TestCase{ + ProtoV5ProviderFactories: testAccProtoV5MuxServerFactory, + Steps: []resource.TestStep{ + { + PreConfig: func() { + t.Setenv("HONEYCOMB_API_KEY", "") + }, + // plugin SDK-based + Config: `data "honeycombio_datasets" "all" {}`, + ExpectError: regexp.MustCompile(`No v1 API client configured`), + }, + // framework-based + { + PreConfig: func() { + t.Setenv("HONEYCOMB_API_KEY", "") + }, + Config: `data "honeycombio_auth_metadata" "current" {}`, + ExpectError: regexp.MustCompile(`No v1 API client configured`), + }, }, - }, + }) }) } diff --git a/internal/provider/query_resource.go b/internal/provider/query_resource.go index d861f3bc..23c5dc6f 100644 --- a/internal/provider/query_resource.go +++ b/internal/provider/query_resource.go @@ -41,7 +41,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) { 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..124ec645 100644 --- a/internal/provider/trigger_resource.go +++ b/internal/provider/trigger_resource.go @@ -214,8 +214,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) {