From eb4198afc086bba12ff5bbb1c65ee24f41047d77 Mon Sep 17 00:00:00 2001 From: Sean Gillespie Date: Tue, 20 Feb 2024 12:46:37 -0800 Subject: [PATCH 1/2] feature: Namespace Search Attributes This commit implements a `temporalcloud_namespace_search_attribute` resource that can be used to create search attributes. Search attributes can't currently be deleted at an API level and this commit only implements `Create`. `Read`, `Update`, and `Delete` will come in another PR. --- docs/resources/namespace_search_attribute.md | 26 +++ .../namespace_search_attribute/ca.pem | 12 ++ .../namespace_search_attribute/main.tf | 28 +++ go.mod | 3 +- go.sum | 2 + .../namespace_search_attribute_resource.go | 191 ++++++++++++++++++ ...amespace_search_attribute_resource_test.go | 69 +++++++ internal/provider/provider.go | 1 + 8 files changed, 331 insertions(+), 1 deletion(-) create mode 100644 docs/resources/namespace_search_attribute.md create mode 100644 examples/resources/namespace_search_attribute/ca.pem create mode 100644 examples/resources/namespace_search_attribute/main.tf create mode 100644 internal/provider/namespace_search_attribute_resource.go create mode 100644 internal/provider/namespace_search_attribute_resource_test.go diff --git a/docs/resources/namespace_search_attribute.md b/docs/resources/namespace_search_attribute.md new file mode 100644 index 0000000..622f661 --- /dev/null +++ b/docs/resources/namespace_search_attribute.md @@ -0,0 +1,26 @@ +--- +# generated by https://github.com/hashicorp/terraform-plugin-docs +page_title: "temporalcloud_namespace_search_attribute Resource - terraform-provider-temporalcloud" +subcategory: "" +description: |- + +--- + +# temporalcloud_namespace_search_attribute (Resource) + + + + + + +## Schema + +### Required + +- `name` (String) The name of the search attribute +- `namespace_id` (String) The ID of the namespace to which this search attribute belongs +- `type` (String) The type of the search attribute + +### Read-Only + +- `id` (String) The ID of this search attribute diff --git a/examples/resources/namespace_search_attribute/ca.pem b/examples/resources/namespace_search_attribute/ca.pem new file mode 100644 index 0000000..453649e --- /dev/null +++ b/examples/resources/namespace_search_attribute/ca.pem @@ -0,0 +1,12 @@ +-----BEGIN CERTIFICATE----- +MIIByTCCAVCgAwIBAgIRAWHkC+6JUf3s9Tq43mdp2zgwCgYIKoZIzj0EAwMwEzER +MA8GA1UEChMIdGVtcG9yYWwwHhcNMjMwODEwMDAwOTQ1WhcNMjQwODA5MDAxMDQ1 +WjATMREwDwYDVQQKEwh0ZW1wb3JhbDB2MBAGByqGSM49AgEGBSuBBAAiA2IABCzQ +7DwwGSQKM6Zrx3Qtw7IubfxiJ3RSXCqmcGhEbFVeocwAdEgMYlwSlUiWtDZVR2dM +XM9UZLWK4aGGnDNS5Mhcz6ibSBS7Owf4tRZZA9SpFCjNw2HraaiUVV+EUgxoe6No +MGYwDgYDVR0PAQH/BAQDAgGGMA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFG4N +8lIXqQKxwVs/ixVzdF6XGZm+MCQGA1UdEQQdMBuCGWNsaWVudC5yb290LnRlbXBv +cmFsLlB1VHMwCgYIKoZIzj0EAwMDZwAwZAIwRLfm9S7rKGd30KdQvUMcOcDJlmDw +6/oM6UOJFxLeGcpYbgxQ/bFize+Yx9Q9kNeMAjA7GiFsaipaKtWHy5MCOCas3ZP6 ++ttLaXNXss3Z5Wk5vhDQnyE8JR3rPeQ2cHXLiA0= +-----END CERTIFICATE----- diff --git a/examples/resources/namespace_search_attribute/main.tf b/examples/resources/namespace_search_attribute/main.tf new file mode 100644 index 0000000..bd4d882 --- /dev/null +++ b/examples/resources/namespace_search_attribute/main.tf @@ -0,0 +1,28 @@ +provider "temporalcloud" { + +} + +resource "temporalcloud_namespace" "terraform" { + name = "terraform-with-search-attributes" + regions = ["aws-us-east-1"] + accepted_client_ca = base64encode(file("${path.module}/ca.pem")) + retention_days = 14 +} + +resource "temporalcloud_namespace_search_attribute" "custom_search_attribute" { + namespace_id = temporalcloud_namespace.terraform.id + name = "CustomSearchAttribute" + type = "Text" +} + +resource "temporalcloud_namespace_search_attribute" "custom_search_attribute2" { + namespace_id = temporalcloud_namespace.terraform.id + name = "CustomSearchAttribute2" + type = "Text" +} + +resource "temporalcloud_namespace_search_attribute" "custom_search_attribute3" { + namespace_id = temporalcloud_namespace.terraform.id + name = "CustomSearchAttribute3" + type = "Text" +} diff --git a/go.mod b/go.mod index fda8804..b0f4ad7 100644 --- a/go.mod +++ b/go.mod @@ -3,12 +3,14 @@ module github.com/temporalio/terraform-provider-temporalcloud go 1.21 require ( + github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/terraform-plugin-docs v0.18.0 github.com/hashicorp/terraform-plugin-framework v1.3.5 github.com/hashicorp/terraform-plugin-framework-timeouts v0.4.1 github.com/hashicorp/terraform-plugin-go v0.18.0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-testing v1.4.0 + github.com/jpillora/maplock v0.0.0-20160420012925-5c725ac6e22a google.golang.org/genproto/googleapis/api v0.0.0-20231106174013-bbf56f31fb17 google.golang.org/grpc v1.61.1 google.golang.org/protobuf v1.31.0 @@ -38,7 +40,6 @@ require ( github.com/hashicorp/go-hclog v1.5.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-plugin v1.4.10 // indirect - github.com/hashicorp/go-uuid v1.0.3 // indirect github.com/hashicorp/go-version v1.6.0 // indirect github.com/hashicorp/hc-install v0.6.2 // indirect github.com/hashicorp/hcl/v2 v2.17.0 // indirect diff --git a/go.sum b/go.sum index 3a9bdfb..5b714c6 100644 --- a/go.sum +++ b/go.sum @@ -124,6 +124,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jhump/protoreflect v1.6.0 h1:h5jfMVslIg6l29nsMs0D8Wj17RDVdNYti0vDN/PZZoE= github.com/jhump/protoreflect v1.6.0/go.mod h1:eaTn3RZAmMBcV0fifFvlm6VHNz3wSkYyXYWUh7ymB74= +github.com/jpillora/maplock v0.0.0-20160420012925-5c725ac6e22a h1:40K0UjFKjfaXcJaGMgf9C0fOzwDxPZMOI0CPbNP89cQ= +github.com/jpillora/maplock v0.0.0-20160420012925-5c725ac6e22a/go.mod h1:bn3xq9G+QDq2j6fczyaTq47L6980t7/NnqCnCK7kqD0= github.com/kevinburke/ssh_config v1.2.0 h1:x584FjTGwHzMwvHx18PXxbBVzfnxogHaAReU4gf13a4= github.com/kevinburke/ssh_config v1.2.0/go.mod h1:CT57kijsi8u/K/BOFA39wgDQJ9CxiF4nAY/ojJ6r6mM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= diff --git a/internal/provider/namespace_search_attribute_resource.go b/internal/provider/namespace_search_attribute_resource.go new file mode 100644 index 0000000..f313556 --- /dev/null +++ b/internal/provider/namespace_search_attribute_resource.go @@ -0,0 +1,191 @@ +package provider + +import ( + "context" + "fmt" + + "github.com/hashicorp/go-uuid" + "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" + "github.com/jpillora/maplock" + "github.com/temporalio/terraform-provider-temporalcloud/internal/client" + + cloudservicev1 "github.com/temporalio/terraform-provider-temporalcloud/proto/go/temporal/api/cloud/cloudservice/v1" +) + +type ( + namespaceSearchAttributeResource struct { + client cloudservicev1.CloudServiceClient + } + + namespaceSearchAttributeModel struct { + ID types.String `tfsdk:"id"` + NamespaceID types.String `tfsdk:"namespace_id"` + Name types.String `tfsdk:"name"` + Type types.String `tfsdk:"type"` + } +) + +var ( + _ resource.Resource = (*namespaceSearchAttributeResource)(nil) + _ resource.ResourceWithConfigure = (*namespaceSearchAttributeResource)(nil) + + // namespaceLocks is a per-namespace mutex that protects against concurrent updates to the same namespace spec, + // which can happen when we are modifying multiple search attributes in parallel. + namespaceLocks = maplock.New() +) + +func NewNamespaceSearchAttributeResource() resource.Resource { + return &namespaceSearchAttributeResource{} +} + +func (r *namespaceSearchAttributeResource) Configure(_ context.Context, req resource.ConfigureRequest, resp *resource.ConfigureResponse) { + if req.ProviderData == nil { + return + } + + client, ok := req.ProviderData.(cloudservicev1.CloudServiceClient) + if !ok { + resp.Diagnostics.AddError( + "Unexpected Data Source Configure Type", + fmt.Sprintf("Expected cloudservicev1.CloudServiceClient, got: %T. Please report this issue to the provider developers.", req.ProviderData), + ) + + return + } + + r.client = client +} + +func (r *namespaceSearchAttributeResource) Metadata(_ context.Context, req resource.MetadataRequest, resp *resource.MetadataResponse) { + resp.TypeName = req.ProviderTypeName + "_namespace_search_attribute" +} + +func (r *namespaceSearchAttributeResource) Schema(ctx context.Context, _ resource.SchemaRequest, resp *resource.SchemaResponse) { + resp.Schema = schema.Schema{ + Attributes: map[string]schema.Attribute{ + "id": schema.StringAttribute{ + Description: "The ID of this search attribute", + Computed: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.UseStateForUnknown(), + }, + }, + "namespace_id": schema.StringAttribute{ + Description: "The ID of the namespace to which this search attribute belongs", + Required: true, + PlanModifiers: []planmodifier.String{ + stringplanmodifier.RequiresReplace(), + }, + }, + "name": schema.StringAttribute{ + Description: "The name of the search attribute", + Required: true, + }, + "type": schema.StringAttribute{ + Description: "The type of the search attribute", + Required: true, + }, + }, + } +} + +func (r *namespaceSearchAttributeResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) { + var plan namespaceSearchAttributeModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + withNamespaceLock(plan.NamespaceID.ValueString(), func() { + ns, err := r.client.GetNamespace(ctx, &cloudservicev1.GetNamespaceRequest{ + Namespace: plan.NamespaceID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to get namespace", err.Error()) + return + } + + spec := ns.GetNamespace().GetSpec() + if spec.GetCustomSearchAttributes() == nil { + spec.CustomSearchAttributes = make(map[string]string) + } + if _, present := spec.GetCustomSearchAttributes()[plan.Name.ValueString()]; present { + resp.Diagnostics.AddError( + "Search attribute already exists", + fmt.Sprintf("Search attribute with name `%s` already exists on namespace `%s`", plan.Name.ValueString(), plan.NamespaceID.ValueString()), + ) + return + } + + spec.GetCustomSearchAttributes()[plan.Name.ValueString()] = plan.Type.ValueString() + svcResp, err := r.client.UpdateNamespace(ctx, &cloudservicev1.UpdateNamespaceRequest{ + Namespace: plan.NamespaceID.ValueString(), + Spec: spec, + ResourceVersion: ns.GetNamespace().GetResourceVersion(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to update namespace", err.Error()) + return + } + + if err := client.AwaitAsyncOperation(ctx, r.client, svcResp.GetAsyncOperation()); err != nil { + resp.Diagnostics.AddError("Failed to update namespace", err.Error()) + return + } + }) + if resp.Diagnostics.HasError() { + return + } + + updatedNs, err := r.client.GetNamespace(ctx, &cloudservicev1.GetNamespaceRequest{ + Namespace: plan.NamespaceID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to get namespace after update", err.Error()) + return + } + + id, err := uuid.GenerateUUID() + if err != nil { + resp.Diagnostics.AddError("Failed to generate UUID", err.Error()) + return + } + + newCSA := updatedNs.GetNamespace().GetSpec().GetCustomSearchAttributes() + newSearchAttrType, ok := newCSA[plan.Name.ValueString()] + if !ok { + resp.Diagnostics.AddError( + "Failed to find newly-created search attribute", + fmt.Sprintf("Failed to find search attribute `%s` after update (this is a bug, please report this on GitHub!)", plan.Name.ValueString()), + ) + return + } + plan.ID = types.StringValue(id) + plan.NamespaceID = types.StringValue(updatedNs.GetNamespace().GetNamespace()) + // plan.Name is already set + plan.Type = types.StringValue(newSearchAttrType) + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) +} + +func (r *namespaceSearchAttributeResource) Read(ctx context.Context, req resource.ReadRequest, resp *resource.ReadResponse) { + // TODO: NYI +} + +func (r *namespaceSearchAttributeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { + // TODO: NYI +} + +func (r *namespaceSearchAttributeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { + // TODO: NYI +} + +// withNamespaceLock locks the given namespace and runs the given function, releasing the lock once the function returns. +func withNamespaceLock(ns string, f func()) { + namespaceLocks.Lock(ns) + defer namespaceLocks.Unlock(ns) + f() +} diff --git a/internal/provider/namespace_search_attribute_resource_test.go b/internal/provider/namespace_search_attribute_resource_test.go new file mode 100644 index 0000000..5dbdba7 --- /dev/null +++ b/internal/provider/namespace_search_attribute_resource_test.go @@ -0,0 +1,69 @@ +package provider + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func TestAccNamespaceWithSearchAttributes(t *testing.T) { + t.Parallel() + name := fmt.Sprintf("%s-%s", "tf-search-attributes", randomString()) + config := func(name string) string { + return fmt.Sprintf(` +provider "temporalcloud" { + +} + +resource "temporalcloud_namespace" "terraform" { + name = "%s" + regions = ["aws-us-east-1"] + retention_days = 7 + accepted_client_ca = base64encode(< Date: Tue, 20 Feb 2024 17:02:20 -0800 Subject: [PATCH 2/2] feature: search attribute renaming This commit implements the `Update` path for search attributes by renaming them via the `RenameCustomSearchAttribute` API. --- .../namespace_search_attribute_resource.go | 80 ++++++++++++++++++- ...amespace_search_attribute_resource_test.go | 11 ++- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/internal/provider/namespace_search_attribute_resource.go b/internal/provider/namespace_search_attribute_resource.go index f313556..33ca771 100644 --- a/internal/provider/namespace_search_attribute_resource.go +++ b/internal/provider/namespace_search_attribute_resource.go @@ -176,7 +176,85 @@ func (r *namespaceSearchAttributeResource) Read(ctx context.Context, req resourc } func (r *namespaceSearchAttributeResource) Update(ctx context.Context, req resource.UpdateRequest, resp *resource.UpdateResponse) { - // TODO: NYI + var plan, state namespaceSearchAttributeModel + resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...) + if resp.Diagnostics.HasError() { + return + } + + resp.Diagnostics.Append(req.State.Get(ctx, &state)...) + if resp.Diagnostics.HasError() { + return + } + + withNamespaceLock(plan.NamespaceID.ValueString(), func() { + ns, err := r.client.GetNamespace(ctx, &cloudservicev1.GetNamespaceRequest{ + Namespace: plan.NamespaceID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to get namespace after update", err.Error()) + return + } + + if !plan.Name.Equal(state.Name) { + svcResp, err := r.client.RenameCustomSearchAttribute(ctx, &cloudservicev1.RenameCustomSearchAttributeRequest{ + Namespace: plan.NamespaceID.ValueString(), + ExistingCustomSearchAttributeName: state.Name.ValueString(), + NewCustomSearchAttributeName: plan.Name.ValueString(), + ResourceVersion: ns.GetNamespace().GetResourceVersion(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to rename search attribute", err.Error()) + return + } + + if err := client.AwaitAsyncOperation(ctx, r.client, svcResp.GetAsyncOperation()); err != nil { + resp.Diagnostics.AddError("Failed to rename search attribute", err.Error()) + return + } + } + + spec := ns.GetNamespace().GetSpec() + // Assumption: a search attribute named plan.Name already exists + spec.GetCustomSearchAttributes()[plan.Name.ValueString()] = plan.Type.ValueString() + svcResp, err := r.client.UpdateNamespace(ctx, &cloudservicev1.UpdateNamespaceRequest{ + Namespace: plan.NamespaceID.ValueString(), + Spec: spec, + ResourceVersion: ns.GetNamespace().GetResourceVersion(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to update namespace", err.Error()) + return + } + + if err := client.AwaitAsyncOperation(ctx, r.client, svcResp.GetAsyncOperation()); err != nil { + resp.Diagnostics.AddError("Failed to update namespace", err.Error()) + return + } + + updatedNs, err := r.client.GetNamespace(ctx, &cloudservicev1.GetNamespaceRequest{ + Namespace: plan.NamespaceID.ValueString(), + }) + if err != nil { + resp.Diagnostics.AddError("Failed to get namespace after update", err.Error()) + return + } + + newCSA := updatedNs.GetNamespace().GetSpec().GetCustomSearchAttributes() + newSearchAttrType, ok := newCSA[plan.Name.ValueString()] + if !ok { + resp.Diagnostics.AddError( + "Failed to find newly-created search attribute", + fmt.Sprintf("Failed to find search attribute `%s` after update (this is a bug, please report this on GitHub!)", plan.Name.ValueString()), + ) + return + } + // plan.ID does not change + plan.NamespaceID = types.StringValue(updatedNs.GetNamespace().GetNamespace()) + // plan.Name is already set + plan.Type = types.StringValue(newSearchAttrType) + resp.Diagnostics.Append(resp.State.Set(ctx, plan)...) + }) } func (r *namespaceSearchAttributeResource) Delete(ctx context.Context, req resource.DeleteRequest, resp *resource.DeleteResponse) { diff --git a/internal/provider/namespace_search_attribute_resource_test.go b/internal/provider/namespace_search_attribute_resource_test.go index 5dbdba7..11c2d10 100644 --- a/internal/provider/namespace_search_attribute_resource_test.go +++ b/internal/provider/namespace_search_attribute_resource_test.go @@ -10,7 +10,7 @@ import ( func TestAccNamespaceWithSearchAttributes(t *testing.T) { t.Parallel() name := fmt.Sprintf("%s-%s", "tf-search-attributes", randomString()) - config := func(name string) string { + config := func(name string, saName string) string { return fmt.Sprintf(` provider "temporalcloud" { @@ -39,7 +39,7 @@ PEM resource "temporalcloud_namespace_search_attribute" "custom_search_attribute" { namespace_id = temporalcloud_namespace.terraform.id - name = "CustomSearchAttribute" + name = "%s" type = "Text" } @@ -53,7 +53,7 @@ resource "temporalcloud_namespace_search_attribute" "custom_search_attribute3" { namespace_id = temporalcloud_namespace.terraform.id name = "CustomSearchAttribute3" type = "Text" -}`, name) +}`, name, saName) } resource.Test(t, resource.TestCase{ @@ -61,7 +61,10 @@ resource "temporalcloud_namespace_search_attribute" "custom_search_attribute3" { ProtoV6ProviderFactories: testAccProtoV6ProviderFactories, Steps: []resource.TestStep{ { - Config: config(name), + Config: config(name, "CustomSearchAttribute"), + }, + { + Config: config(name, "CustomSearchAttribute9"), }, }, })