From ba3318115e5fbf3a5d467a6ffb7933ec48c9d684 Mon Sep 17 00:00:00 2001 From: Christopher Fitzner Date: Tue, 23 Apr 2024 17:57:58 -0700 Subject: [PATCH] Add the api key resource An Api Key will authenticate a service account for use with the cockroach cloud api. Service accounts can have multiple api keys attached to them. --- CHANGELOG.md | 2 + docs/resources/api_key.md | 57 ++++ .../resources/cockroach_api_key/import.sh | 4 + .../resources/cockroach_api_key/resource.tf | 10 + .../cockroach_serverless_cluster/main.tf | 25 ++ internal/provider/allowlist_resource.go | 1 + internal/provider/api_key_resource.go | 290 ++++++++++++++++++ internal/provider/api_key_resource_test.go | 212 +++++++++++++ internal/provider/database_resource.go | 2 - internal/provider/folder_data_source.go | 3 +- internal/provider/models.go | 8 + .../private_endpoint_connection_resource.go | 2 - ...private_endpoint_trusted_owner_resource.go | 2 - internal/provider/provider.go | 1 + internal/provider/sql_user_resource.go | 2 - internal/provider/utils.go | 15 +- 16 files changed, 624 insertions(+), 12 deletions(-) create mode 100644 docs/resources/api_key.md create mode 100644 examples/resources/cockroach_api_key/import.sh create mode 100644 examples/resources/cockroach_api_key/resource.tf create mode 100644 internal/provider/api_key_resource.go create mode 100644 internal/provider/api_key_resource_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 12f0fcba..28d659c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## Added +- The [cockroach_api_key](https://registry.terraform.io/providers/cockroachdb/cockroach/latest/docs/resources/api_key) resource was added. + - The [cockroach_service_account](https://registry.terraform.io/providers/cockroachdb/cockroach/latest/docs/resources/service_account) resource was added. ## [1.4.1] - 2024-04-04 diff --git a/docs/resources/api_key.md b/docs/resources/api_key.md new file mode 100644 index 00000000..e380c9cd --- /dev/null +++ b/docs/resources/api_key.md @@ -0,0 +1,57 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "cockroach_api_key Resource - terraform-provider-cockroach" +subcategory: "" +description: |- + API Keys can be used for programmatic access to the cockroach cloud api. Each key is mapped to a cockroachserviceaccount service_account. + To access the secret, declare an output value for it and use the terraform output command. i.e. terraform output -raw example_secret + During API key creation, a sensitive key is created and stored in the terraform state. Always follow best practices https://developer.hashicorp.com/terraform/tutorials/configuration-language/sensitive-variables#sensitive-values-in-state when managing sensitive data. +--- + +# cockroach_api_key (Resource) + +API Keys can be used for programmatic access to the cockroach cloud api. Each key is mapped to a [cockroach_service_account](service_account). + +To access the secret, declare an output value for it and use the terraform output command. i.e. `terraform output -raw example_secret` + +During API key creation, a sensitive key is created and stored in the terraform state. Always follow [best practices](https://developer.hashicorp.com/terraform/tutorials/configuration-language/sensitive-variables#sensitive-values-in-state) when managing sensitive data. + +## Example Usage + +```terraform +resource "cockroach_api_key" "example" { + name = "An example api key" + service_account_id = cockroach_service_account.example_sa.id +} + +output "example_secret" { + value = cockroach_api_key.example.secret + description = "The api key for the example api key" + sensitive = true +} +``` + + +## Schema + +### Required + +- `name` (String) Name of the api key. +- `service_account_id` (String) + +### Read-Only + +- `created_at` (String) Creation time of the api key. +- `id` (String) The ID of this resource. +- `secret` (String, Sensitive) + +## Import + +Import is supported using the following syntax: + +```shell +# Since the secret, is not retreivable after creation, it must be provided +# during import. The API key ID can be derived from the secret. +# format: terraform import +terraform import cockroach_api_key.example CCDB1_D4zMI3pZTmk5rGrzYqMhbc_NkcXLI8d81Mtx3djD45iwPfgtnaRv0XCh0Z9047K +``` diff --git a/examples/resources/cockroach_api_key/import.sh b/examples/resources/cockroach_api_key/import.sh new file mode 100644 index 00000000..37cff604 --- /dev/null +++ b/examples/resources/cockroach_api_key/import.sh @@ -0,0 +1,4 @@ +# Since the secret, is not retreivable after creation, it must be provided +# during import. The API key ID can be derived from the secret. +# format: terraform import +terraform import cockroach_api_key.example CCDB1_D4zMI3pZTmk5rGrzYqMhbc_NkcXLI8d81Mtx3djD45iwPfgtnaRv0XCh0Z9047K diff --git a/examples/resources/cockroach_api_key/resource.tf b/examples/resources/cockroach_api_key/resource.tf new file mode 100644 index 00000000..bb7385c1 --- /dev/null +++ b/examples/resources/cockroach_api_key/resource.tf @@ -0,0 +1,10 @@ +resource "cockroach_api_key" "example" { + name = "An example api key" + service_account_id = cockroach_service_account.example_sa.id +} + +output "example_secret" { + value = cockroach_api_key.example.secret + description = "The api key for the example api key" + sensitive = true +} diff --git a/examples/workflows/cockroach_serverless_cluster/main.tf b/examples/workflows/cockroach_serverless_cluster/main.tf index 10b2171d..0667b438 100644 --- a/examples/workflows/cockroach_serverless_cluster/main.tf +++ b/examples/workflows/cockroach_serverless_cluster/main.tf @@ -67,3 +67,28 @@ resource "cockroach_database" "example" { name = "example-database" cluster_id = cockroach_cluster.example.id } + +resource "cockroach_service_account" "example_scoped_sa" { + name = "example-scoped-service-account" + description = "A service account providing limited read access to single cluster." +} + +resource "cockroach_user_role_grant" "example_limited_access_scoped_grant" { + user_id = cockroach_service_account.example_scoped_sa.id + role = { + role_name = "CLUSTER_OPERATOR_WRITER", + resource_type = "CLUSTER", + resource_id = cockroach_cluster.example.id + } +} + +resource "cockroach_api_key" "example_cluster_op_key_v1" { + name = "example-cluster-operator-key-v1" + service_account_id = cockroach_service_account.example_scoped_sa.id +} + +output "example_cluster_op_key_v1_secret" { + value = cockroach_api_key.example_cluster_op_key_v1.secret + description = "The api key for example_cluster_op_key_v1_secret" + sensitive = true +} diff --git a/internal/provider/allowlist_resource.go b/internal/provider/allowlist_resource.go index 2fb81c37..777a3057 100644 --- a/internal/provider/allowlist_resource.go +++ b/internal/provider/allowlist_resource.go @@ -334,6 +334,7 @@ func (r *allowListResource) ImportState( resp.Diagnostics.AddError( "Invalid allowlist entry ID format", `When importing an allowlist entry, the ID field should follow the format ":/")`) + return } // We can swallow this error because it's already been regex-validated. mask, _ = strconv.Atoi(matches[4]) diff --git a/internal/provider/api_key_resource.go b/internal/provider/api_key_resource.go new file mode 100644 index 00000000..c6ec8391 --- /dev/null +++ b/internal/provider/api_key_resource.go @@ -0,0 +1,290 @@ +package provider + +import ( + "context" + "fmt" + "net/http" + "strings" + + "github.com/cockroachdb/cockroach-cloud-sdk-go/pkg/client" + "github.com/hashicorp/terraform-plugin-framework/resource" + "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier" + "github.com/hashicorp/terraform-plugin-framework/resource/schema/stringplanmodifier" + "github.com/hashicorp/terraform-plugin-framework/types" +) + +type apiKeyResource struct { + provider *provider +} + +func NewAPIKeyResource() resource.Resource { + return &apiKeyResource{} +} + +func (r *apiKeyResource) Schema( + _ context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse, +) { + resp.Schema = schema.Schema{ + MarkdownDescription: `API Keys can be used for programmatic access to the cockroach cloud api. Each key is mapped to a [cockroach_service_account](service_account). + +To access the secret, declare an output value for it and use the terraform output command. i.e. ` + "`terraform output -raw example_secret`" + ` + +During API key creation, a sensitive key is created and stored in the terraform state. Always follow [best practices](https://developer.hashicorp.com/terraform/tutorials/configuration-language/sensitive-variables#sensitive-values-in-state) when managing sensitive data.`, + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "service_account_id": schema.StringAttribute{ + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + Validators: uuidValidator, + }, + "name": schema.StringAttribute{ + MarkdownDescription: "Name of the api key.", + Required: true, + }, + "created_at": schema.StringAttribute{ + MarkdownDescription: "Creation time of the api key.", + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + Computed: true, + }, + "secret": schema.StringAttribute{ + Computed: true, + Sensitive: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + }, + } +} + +func (r *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, +) { + if req.ProviderData == nil { + return + } + var ok bool + if r.provider, ok = req.ProviderData.(*provider); !ok { + resp.Diagnostics.AddError("Internal provider error", + fmt.Sprintf("Error in Configure: expected %T but got %T", provider{}, req.ProviderData)) + } +} + +func (r *apiKeyResource) Create( + ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse, +) { + if r.provider == nil || !r.provider.configured { + addConfigureProviderErr(&resp.Diagnostics) + return + } + + var plan APIKey + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + traceAPICall("CreateAPIKey") + createResp, _, err := r.provider.service.CreateApiKey(ctx, &client.CreateApiKeyRequest{ + Name: plan.Name.ValueString(), + ServiceAccountId: plan.ServiceAccountID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError( + "Error creating api key", + fmt.Sprintf("Could not create api key: %s", formatAPIErrorMessage(err)), + ) + return + } + + var state APIKey + loadAPIKeyToTerraformState(&createResp.ApiKey, &createResp.Secret, &state) + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *apiKeyResource) Read( + ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse, +) { + if r.provider == nil || !r.provider.configured { + addConfigureProviderErr(&resp.Diagnostics) + return + } + + var state APIKey + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + + if resp.Diagnostics.HasError() { + return + } + if state.ID.IsNull() { + return + } + + apiKeyID := state.ID.ValueString() + + traceAPICall("GetApiKey") + apiKeyObj, httpResp, err := r.provider.service.GetApiKey(ctx, apiKeyID) + if err != nil { + if httpResp != nil && httpResp.StatusCode == http.StatusNotFound { + resp.Diagnostics.AddWarning( + "API key not found", + fmt.Sprintf("API key with ID %s is not found. Removing from state.", apiKeyID)) + resp.State.RemoveResource(ctx) + } else { + resp.Diagnostics.AddError( + "Error getting api key info", + fmt.Sprintf("Unexpected error retrieving api key info: %s", formatAPIErrorMessage(err))) + } + return + } + + loadAPIKeyToTerraformState(apiKeyObj, nil /* secret */, &state) + + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *apiKeyResource) Update( + ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse, +) { + // Get api key specification. + var plan APIKey + diags := req.Plan.Get(ctx, &plan) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get current state. + var state APIKey + diags = req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // State is the only updateable field currently so if state didn't change, + // just return. + newName := plan.Name.ValueString() + if state.Name.ValueString() == newName { + return + } + + traceAPICall("UpdateAPIKey") + apiKeyObj, _, err := r.provider.service.UpdateApiKey( + ctx, + plan.ID.ValueString(), + &client.UpdateApiKeySpecification{ + Name: &newName, + }) + if err != nil { + resp.Diagnostics.AddError( + "Error updating api key", + fmt.Sprintf("Could not update api key: %s", formatAPIErrorMessage(err)), + ) + return + } + + loadAPIKeyToTerraformState(apiKeyObj, nil, &state) + diags = resp.State.Set(ctx, state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } +} + +func (r *apiKeyResource) Delete( + ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse, +) { + var state APIKey + diags := req.State.Get(ctx, &state) + resp.Diagnostics.Append(diags...) + if resp.Diagnostics.HasError() { + return + } + + // Get api key ID from state. + apiKeyID := state.ID + if apiKeyID.IsNull() { + return + } + + traceAPICall("DeleteAPIKey") + _, httpResp, err := r.provider.service.DeleteApiKey(ctx, apiKeyID.ValueString()) + if err != nil { + if httpResp != nil && httpResp.StatusCode == http.StatusNotFound { + // API key is already gone. Swallow the error. + } else { + resp.Diagnostics.AddError( + "Error deleting api key", + fmt.Sprintf("Could not delete api key: %s", formatAPIErrorMessage(err)), + ) + } + return + } + + // Remove resource from state + resp.State.RemoveResource(ctx) +} + +func (r *apiKeyResource) ImportState( + ctx context.Context, req resource.ImportStateRequest, resp *resource.ImportStateResponse, +) { + // Since the api key secret is not accessible via the api after create has + // already occurred, in order to support import it must also be specified. + // The secret also includes the api key id as a prefix so we can just ask + // for the secret and get both values. + parts := strings.Split(req.ID, "_") + + if len(parts) != 3 { + resp.Diagnostics.AddError( + "Invalid API Key secret format", + `When importing an api key, the secret must be provided because it cannot be fetched via the api. The format should match CCDB1_<22 chars>_<40 chars>.`) + return + } + + // build the api key and let the remote READ handle the remaining validations. + apiKeyID := fmt.Sprintf("%s_%s", parts[0], parts[1]) + secret := req.ID + + apiKey := APIKey{ + ID: types.StringValue(apiKeyID), + Secret: types.StringValue(secret), + } + resp.Diagnostics = resp.State.Set(ctx, &apiKey) +} + +func loadAPIKeyToTerraformState(apiKeyObj *client.ApiKey, secret *string, state *APIKey) { + state.ID = types.StringValue(apiKeyObj.Id) + state.Name = types.StringValue(apiKeyObj.Name) + state.ServiceAccountID = types.StringValue(apiKeyObj.ServiceAccountId) + state.CreatedAt = types.StringValue(apiKeyObj.CreatedAt.String()) + if secret != nil { + state.Secret = types.StringValue(*secret) + } +} diff --git a/internal/provider/api_key_resource_test.go b/internal/provider/api_key_resource_test.go new file mode 100644 index 00000000..e9bf24f7 --- /dev/null +++ b/internal/provider/api_key_resource_test.go @@ -0,0 +1,212 @@ +/* + Copyright 2024 The Cockroach Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package provider + +import ( + "context" + "fmt" + "os" + "testing" + "time" + + "github.com/cockroachdb/cockroach-cloud-sdk-go/pkg/client" + mock_client "github.com/cockroachdb/terraform-provider-cockroach/mock" + "github.com/golang/mock/gomock" + "github.com/google/uuid" + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +// TestIntegrationAPIKeyResource attempts to create, check, and destroy a real +// api key. It will be skipped if TF_ACC isn't set. +func TestAccAPIKeyResource(t *testing.T) { + t.Parallel() + serviceAccountName := fmt.Sprintf("%s-sa-resource-%s", tfTestPrefix, GenerateRandomString(4)) + apiKeyName := fmt.Sprintf("%s-api-key-res-%s", tfTestPrefix, GenerateRandomString(4)) + + testAPIKeyResource(t, serviceAccountName, apiKeyName, apiKeyName + "-updated", false /* , false /* useMock */) +} + +// TestIntegrationAPIKeyResource attempts to create, check, and destroy a api +// key but uses a mocked API service. +func TestIntegrationAPIKeyResource(t *testing.T) { + apiKeyName := "test-api-key-name" + apiKeyNameUpdated := apiKeyName + "-updated" + if os.Getenv(CockroachAPIKey) == "" { + os.Setenv(CockroachAPIKey, "fake") + } + + ctrl := gomock.NewController(t) + s := mock_client.NewMockService(ctrl) + defer HookGlobal(&NewService, func(c *client.Client) client.Service { + return s + })() + + saID := uuid.Must(uuid.NewUUID()).String() + serviceAccountName := "a service account" + serviceAccount := &client.ServiceAccount{ + Id: saID, + Name: serviceAccountName, + CreatorName: "somebody", + CreatedAt: time.Now(), + GroupRoles: []client.BuiltInFromGroups{}, + Roles: []client.BuiltInRole{{ + Name: client.ORGANIZATIONUSERROLETYPE_ORG_MEMBER, + Resource: client.Resource{ + Type: client.RESOURCETYPETYPE_ORGANIZATION, + }, + }}, + } + + apiKeyID := "CCDB1_D4zlI3pmTmk5zGrzYbMhbc" + createTime := time.Now() + secret := apiKeyID + "_abcdeFGd81Mtx3djD45iwPfgtnaRv01234Z9047K" + apiKey := &client.ApiKey{ + Id: apiKeyID, + Name: apiKeyName, + CreatedAt: createTime, + ServiceAccountId: saID, + } + + // Called by Service Account Create (spin up supporting resources) + s.EXPECT().CreateServiceAccount(gomock.Any(), &client.CreateServiceAccountRequest{ + Name: serviceAccountName, + Description: "", + Roles: []client.BuiltInRole{}, + }).Return(serviceAccount, nil, nil) + + // Called by Create + s.EXPECT().CreateApiKey(gomock.Any(), &client.CreateApiKeyRequest{ + Name: apiKeyName, + ServiceAccountId: saID, + }).Return(&client.CreateApiKeyResponse{ + ApiKey: *apiKey, + Secret: secret, + }, nil, nil) + + // Called by testAPIKeyExists + s.EXPECT().GetApiKey(gomock.Any(), apiKeyID).Return(apiKey, nil, nil) + + // Called by Read prior to Update, I'm not sure why there are 2 sets of these + s.EXPECT().GetServiceAccount(gomock.Any(), saID).Return(serviceAccount, nil, nil).Times(2) + s.EXPECT().GetApiKey(gomock.Any(), apiKeyID).Return(apiKey, nil, nil).Times(2) + + // Make a copy + apiKeyUpdated := *apiKey + apiKeyUpdated.Name = apiKeyNameUpdated + + // Called by Update + s.EXPECT().UpdateApiKey(gomock.Any(), apiKeyID, &client.UpdateApiKeySpecification{ + Name: &apiKeyNameUpdated, + }).Return(&apiKeyUpdated, nil, nil) + + // Called by testAPIKeyExists + s.EXPECT().GetApiKey(gomock.Any(), apiKeyID).Return(&apiKeyUpdated, nil, nil) + + // Called by Read as a result of the import test + s.EXPECT().GetServiceAccount(gomock.Any(), saID).Return(serviceAccount, nil, nil) + s.EXPECT().GetApiKey(gomock.Any(), apiKeyID).Return(&apiKeyUpdated, nil, nil) + + // Called by Read prior to Delete + s.EXPECT().GetApiKey(gomock.Any(), apiKeyID).Return(&apiKeyUpdated, nil, nil) + + // Called by Delete + s.EXPECT().DeleteApiKey(gomock.Any(), apiKeyID).Return(&apiKeyUpdated, nil, nil) + + // Called by Service Account Delete (clean up supporting resources) + s.EXPECT().DeleteServiceAccount(gomock.Any(), saID).Return(serviceAccount, nil, nil) + + testAPIKeyResource(t, serviceAccountName, apiKeyName, apiKeyNameUpdated, true /* useMock */) +} + +func testAPIKeyResource(t *testing.T, serviceAccountName, apiKeyName, apiKeyNameUpdated string, useMock bool) { + var ( + apiKeyResourceName = "cockroach_api_key.test_api_key" + ) + resource.Test(t, resource.TestCase{ + IsUnitTest: useMock, + PreCheck: func() { testAccPreCheck(t) }, + ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: getTestAPIKeyResourceConfig(serviceAccountName, apiKeyName), + Check: testAPIKeyExists(apiKeyResourceName), + }, + { + Config: getTestAPIKeyResourceConfig(serviceAccountName, apiKeyNameUpdated), + Check: testAPIKeyExists(apiKeyResourceName), + }, + { + ResourceName: apiKeyResourceName, + ImportState: true, + ImportStateVerify: true, + ImportStateIdFunc: func(s *terraform.State) (string, error) { + resources := s.RootModule().Resources + apiKeyResource, ok := resources[apiKeyResourceName] + if !ok { + return "", fmt.Errorf("not found: %s", apiKeyResource) + } + return apiKeyResource.Primary.Attributes["secret"], nil + }, + }, + }, + }) +} + +func testAPIKeyExists(apiKeyResourceName string) resource.TestCheckFunc { + return func(s *terraform.State) error { + ctx := context.Background() + p := testAccProvider.(*provider) + p.service = NewService(cl) + resources := s.RootModule().Resources + + resource, ok := resources[apiKeyResourceName] + if !ok { + return fmt.Errorf("not found: %s", apiKeyResourceName) + } + apiKeyID := resource.Primary.ID + + traceAPICall("GetApiKey") + resp, _, err := p.service.GetApiKey(ctx, apiKeyID) + if err != nil { + return fmt.Errorf("error fetching api key for id %s: %s", apiKeyID, err.Error()) + } + + if resp.Id == apiKeyID || + resp.Name != resource.Primary.Attributes["name"] { + return nil + } + + return fmt.Errorf( + "Could not find a api key matching expected fields: resp: %v, resource: %v", + resp, + resource, + ) + } +} + +func getTestAPIKeyResourceConfig(serviceAccountName, apiKeyName string) string { + return fmt.Sprintf(` +resource "cockroach_service_account" "test_sa" { + name = "%s" +} +resource "cockroach_api_key" "test_api_key" { + name = "%s" + service_account_id = cockroach_service_account.test_sa.id +} +`, serviceAccountName, apiKeyName) +} diff --git a/internal/provider/database_resource.go b/internal/provider/database_resource.go index f9fbb0e5..9ebe0273 100644 --- a/internal/provider/database_resource.go +++ b/internal/provider/database_resource.go @@ -299,8 +299,6 @@ func (r *databaseResource) ImportState( resp.Diagnostics.AddError( "Invalid database ID format", `When importing a database, the ID field should follow the format ":")`) - } - if resp.Diagnostics.HasError() { return } diff --git a/internal/provider/folder_data_source.go b/internal/provider/folder_data_source.go index d29a8b66..4e9c8de8 100644 --- a/internal/provider/folder_data_source.go +++ b/internal/provider/folder_data_source.go @@ -27,7 +27,6 @@ import ( "github.com/hashicorp/terraform-plugin-framework/datasource" "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/path" - "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-framework/types" ) @@ -50,7 +49,7 @@ func (d *folderDataSource) Schema( "id": schema.StringAttribute{ MarkdownDescription: "The id the folder.", Optional: true, - Validators: []validator.String{uuidValidator}, + Validators: uuidValidator, }, "path": schema.StringAttribute{ MarkdownDescription: "An absolute path to the folder. Trailing slashes are optional. (i.e. /folder1/folder2)", diff --git a/internal/provider/models.go b/internal/provider/models.go index 4dcd059e..b0ce62c2 100644 --- a/internal/provider/models.go +++ b/internal/provider/models.go @@ -318,6 +318,14 @@ type ServiceAccount struct { CreatorName types.String `tfsdk:"creator_name"` } +type APIKey struct { + ID types.String `tfsdk:"id"` + Name types.String `tfsdk:"name"` + ServiceAccountID types.String `tfsdk:"service_account_id"` + CreatedAt types.String `tfsdk:"created_at"` + Secret types.String `tfsdk:"secret"` +} + func (e *APIErrorMessage) String() string { return fmt.Sprintf("%v-%v", e.Code, e.Message) } diff --git a/internal/provider/private_endpoint_connection_resource.go b/internal/provider/private_endpoint_connection_resource.go index 862e8fe7..386fa272 100644 --- a/internal/provider/private_endpoint_connection_resource.go +++ b/internal/provider/private_endpoint_connection_resource.go @@ -259,8 +259,6 @@ func (r *privateEndpointConnectionResource) ImportState( resp.Diagnostics.AddError( "Invalid private endpoint connection ID format", `When importing a private endpoint connection, the ID field should follow the format ":")`) - } - if resp.Diagnostics.HasError() { return } connection := PrivateEndpointConnection{ diff --git a/internal/provider/private_endpoint_trusted_owner_resource.go b/internal/provider/private_endpoint_trusted_owner_resource.go index 98e87908..ef9faf89 100644 --- a/internal/provider/private_endpoint_trusted_owner_resource.go +++ b/internal/provider/private_endpoint_trusted_owner_resource.go @@ -254,8 +254,6 @@ func (r *privateEndpointTrustedOwnerResource) ImportState( resp.Diagnostics.AddError( "Invalid trusted owner terraform ID format", `When importing a trusted owner entry, the ID field should follow the format ":")`) - } - if resp.Diagnostics.HasError() { return } resp.Diagnostics = resp.State.Set(ctx, &PrivateEndpointTrustedOwner{ diff --git a/internal/provider/provider.go b/internal/provider/provider.go index 7ac37a38..e182ce8b 100644 --- a/internal/provider/provider.go +++ b/internal/provider/provider.go @@ -145,6 +145,7 @@ func (p *provider) Resources(_ context.Context) []func() resource.Resource { NewFolderResource, NewApiOidcConfigResource, NewServiceAccountResource, + NewAPIKeyResource, } } diff --git a/internal/provider/sql_user_resource.go b/internal/provider/sql_user_resource.go index 3644808c..c47d7180 100644 --- a/internal/provider/sql_user_resource.go +++ b/internal/provider/sql_user_resource.go @@ -332,8 +332,6 @@ func (r *sqlUserResource) ImportState( resp.Diagnostics.AddError( "Invalid SQL user ID format", `When importing a SQL user, the ID field should follow the format ":")`) - } - if resp.Diagnostics.HasError() { return } sqlUser := SQLUser{ diff --git a/internal/provider/utils.go b/internal/provider/utils.go index 55e02874..0f791ea0 100644 --- a/internal/provider/utils.go +++ b/internal/provider/utils.go @@ -16,6 +16,7 @@ import ( datasource_schema "github.com/hashicorp/terraform-plugin-framework/datasource/schema" "github.com/hashicorp/terraform-plugin-framework/diag" resource_schema "github.com/hashicorp/terraform-plugin-framework/resource/schema" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" "github.com/hashicorp/terraform-plugin-log/tflog" "github.com/stretchr/testify/require" ) @@ -90,10 +91,20 @@ const uuidRegexString = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f var uuidRegex = regexp.MustCompile(uuidRegexString) -var uuidValidator = stringvalidator.RegexMatches( +// uuidValidator is an array of string validators containing just the one +// specific uuid validator. Its specified in this array format for convenience +// because all current and expected future uses won't combine this with other +// validators and it allows using it like so: +// +// "some_id": schema.StringAttribute{ +// Description: "the description.", +// Optional: true, +// Validators: uuidValidator, +// }, +var uuidValidator = []validator.String{stringvalidator.RegexMatches( uuidRegex, "must match UUID format", -) +)} // retryGetRequests implements the retryable-http CheckRetry type. func retryGetRequestsOnly(ctx context.Context, resp *http.Response, err error) (bool, error) {