diff --git a/docs/resources/namespace.md b/docs/resources/namespace.md index 1b9bb37..e2a0fbd 100644 --- a/docs/resources/namespace.md +++ b/docs/resources/namespace.md @@ -155,6 +155,7 @@ resource "temporalcloud_namespace" "terraform4" { - `accepted_client_ca` (String) The Base64-encoded CA cert in PEM format that clients use when authenticating with Temporal Cloud. This is a required field when a Namespace uses mTLS authentication. - `api_key_auth` (Boolean) If true, Temporal Cloud will enable API key authentication for this namespace. +- `capacity` (Attributes) The capacity configuration for the namespace. (see [below for nested schema](#nestedatt--capacity)) - `certificate_filters` (Attributes List) A list of filters to apply to client certificates when initiating a connection Temporal Cloud. If present, connections will only be allowed from client certificates whose distinguished name properties match at least one of the filters. Empty lists are not allowed, omit the attribute instead. (see [below for nested schema](#nestedatt--certificate_filters)) - `codec_server` (Attributes) A codec server is used by the Temporal Cloud UI to decode payloads for all users interacting with this namespace, even if the workflow history itself is encrypted. (see [below for nested schema](#nestedatt--codec_server)) - `connectivity_rule_ids` (List of String) The IDs of the connectivity rules for this namespace. @@ -166,6 +167,15 @@ resource "temporalcloud_namespace" "terraform4" { - `endpoints` (Attributes) The endpoints for the namespace. (see [below for nested schema](#nestedatt--endpoints)) - `id` (String) The unique identifier of the namespace across all Temporal Cloud tenants. + +### Nested Schema for `capacity` + +Optional: + +- `mode` (String) The mode of the capacity configuration. Must be one of 'provisioned' or 'on_demand'. +- `value` (Number) The value of the capacity configuration. Must be set when mode is 'provisioned'. + + ### Nested Schema for `certificate_filters` diff --git a/go.mod b/go.mod index 689dc2c..7e460d5 100644 --- a/go.mod +++ b/go.mod @@ -15,7 +15,7 @@ require ( github.com/hashicorp/terraform-plugin-testing v1.13.3 github.com/jpillora/maplock v0.0.0-20160420012925-5c725ac6e22a go.temporal.io/api v1.53.0 - go.temporal.io/cloud-sdk v0.5.0 + go.temporal.io/cloud-sdk v0.5.1-0.20250924000029-4237f0d769ff go.temporal.io/sdk v1.36.0 google.golang.org/grpc v1.75.1 google.golang.org/protobuf v1.36.9 @@ -34,6 +34,7 @@ require ( github.com/bgentry/speakeasy v0.1.0 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect github.com/cloudflare/circl v1.6.1 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/fatih/color v1.18.0 // indirect github.com/gogo/protobuf v1.3.2 // indirect github.com/golang/protobuf v1.5.4 // indirect @@ -71,6 +72,7 @@ require ( github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.2.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/posener/complete v1.2.3 // indirect github.com/shopspring/decimal v1.3.1 // indirect github.com/spf13/cast v1.5.0 // indirect diff --git a/go.sum b/go.sum index 49a21ef..02e982a 100644 --- a/go.sum +++ b/go.sum @@ -238,8 +238,8 @@ go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mx go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= go.temporal.io/api v1.53.0 h1:6vAFpXaC584AIELa6pONV56MTpkm4Ha7gPWL2acNAjo= go.temporal.io/api v1.53.0/go.mod h1:iaxoP/9OXMJcQkETTECfwYq4cw/bj4nwov8b3ZLVnXM= -go.temporal.io/cloud-sdk v0.5.0 h1:6PdA6D8I/PiFLLpYwinre7ffPTct49zhapMAN5rJjmw= -go.temporal.io/cloud-sdk v0.5.0/go.mod h1:AueDDyuayosk+zalfrnuftRqnRQTHwD0HYwNgEQc0YE= +go.temporal.io/cloud-sdk v0.5.1-0.20250924000029-4237f0d769ff h1:EjXYHBzRlnDlxw+QoUvKd7EbwZywkgjRg1wCC03JABQ= +go.temporal.io/cloud-sdk v0.5.1-0.20250924000029-4237f0d769ff/go.mod h1:AueDDyuayosk+zalfrnuftRqnRQTHwD0HYwNgEQc0YE= go.temporal.io/sdk v1.36.0 h1:WO9zetpybBNK7xsQth4Z+3Zzw1zSaM9MOUGrnnUjZMo= go.temporal.io/sdk v1.36.0/go.mod h1:8BxGRF0LcQlfQrLLGkgVajbsKUp/PY7280XTdcKc18Y= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= diff --git a/internal/provider/namespace_resource.go b/internal/provider/namespace_resource.go index b51799e..c9ca301 100644 --- a/internal/provider/namespace_resource.go +++ b/internal/provider/namespace_resource.go @@ -83,6 +83,7 @@ type ( NamespaceLifecycle internaltypes.ZeroObjectValue `tfsdk:"namespace_lifecycle"` ConnectivityRuleIds internaltypes.UnorderedStringListValue `tfsdk:"connectivity_rule_ids"` Timeouts timeouts.Value `tfsdk:"timeouts"` + Capacity internaltypes.ZeroObjectValue `tfsdk:"capacity"` } lifecycleModel struct { @@ -107,6 +108,11 @@ type ( GrpcAddress types.String `tfsdk:"grpc_address"` MtlsGrpcAddress types.String `tfsdk:"mtls_grpc_address"` } + + capacityModel struct { + Mode types.String `tfsdk:"mode"` + Value types.Float64 `tfsdk:"value"` + } ) var ( @@ -136,6 +142,11 @@ var ( "grpc_address": types.StringType, "mtls_grpc_address": types.StringType, } + + capacityAttrs = map[string]attr.Type{ + "mode": types.StringType, + "value": types.Float64Type, + } ) func NewNamespaceResource() resource.Resource { @@ -304,6 +315,25 @@ func (r *namespaceResource) Schema(ctx context.Context, _ resource.SchemaRequest listvalidator.SizeAtLeast(1), }, }, + "capacity": schema.SingleNestedAttribute{ + Optional: true, + Description: "The capacity configuration for the namespace.", + CustomType: internaltypes.ZeroObjectType{ + ObjectType: basetypes.ObjectType{ + AttrTypes: capacityAttrs, + }, + }, + Attributes: map[string]schema.Attribute{ + "mode": schema.StringAttribute{ + Description: "The mode of the capacity configuration. Must be one of 'provisioned' or 'on_demand'.", + Optional: true, + }, + "value": schema.Float64Attribute{ + Description: "The value of the capacity configuration. Must be set when mode is 'provisioned'.", + Optional: true, + }, + }, + }, }, Blocks: map[string]schema.Block{ "timeouts": timeouts.Block(ctx, timeouts.Opts{ @@ -376,6 +406,19 @@ func (r *namespaceResource) Create(ctx context.Context, req resource.CreateReque ConnectivityRuleIds: connectivityRuleIds, } + if !plan.Capacity.IsNull() { + resp.Diagnostics.AddError("Capacity on namespace creation is not supported", "capacity should be null or not set when creating a namespace") + return + // This will be enabled when capacity on namespace creation is supported + // var d diag.Diagnostics + // capacitySpec, d := getCapacityFromModel(ctx, &plan) + // resp.Diagnostics.Append(d...) + // if resp.Diagnostics.HasError() { + // return + // } + // spec.CapacitySpec = capacitySpec + } + if !plan.ApiKeyAuth.ValueBool() && plan.AcceptedClientCA.IsNull() { resp.Diagnostics.AddError("Namespace not configured with authentication", "accepted_client_ca or api_key_auth must be set") return @@ -571,6 +614,16 @@ func (r *namespaceResource) Update(ctx context.Context, req resource.UpdateReque spec.MtlsAuth = mtls } + if !plan.Capacity.IsNull() { + var d diag.Diagnostics + capacitySpec, d := getCapacityFromModel(ctx, &plan) + resp.Diagnostics.Append(d...) + if resp.Diagnostics.HasError() { + return + } + spec.CapacitySpec = capacitySpec + } + if !areRegionsEqual(currentNs.GetNamespace().GetSpec().GetRegions(), spec.Regions) { resp.Diagnostics.AddError("Namespace regions cannot be changed", "Changing the regions of a namespace is not supported currently via terraform.") return @@ -824,8 +877,41 @@ func updateModelFromSpec( connectivityRuleIdsState = internaltypes.UnorderedStringListValue{ ListValue: planConnectivityRuleIds, } + } + capacitySpec := ns.GetSpec().GetCapacitySpec() + if capacitySpec != nil { + var capacityMode types.String + var capacityValue types.Float64 + if capacitySpec.GetOnDemand() != nil { + capacityMode = types.StringValue("on_demand") + // For on_demand mode, set value to 0 if it's in the current state, otherwise leave it null + if !state.Capacity.IsNull() { + var currentCapacity capacityModel + diags.Append(state.Capacity.As(ctx, ¤tCapacity, basetypes.ObjectAsOptions{})...) + if !diags.HasError() && !currentCapacity.Value.IsNull() { + // Preserve the value from state if it exists + capacityValue = currentCapacity.Value + } + } + } else if capacitySpec.GetProvisioned() != nil { + capacityMode = types.StringValue("provisioned") + capacityValue = types.Float64Value(capacitySpec.GetProvisioned().GetValue()) + } + cp, objectDiags := types.ObjectValueFrom(ctx, capacityAttrs, &capacityModel{ + Mode: capacityMode, + Value: capacityValue, + }) + capacity := internaltypes.ZeroObjectValue{ObjectValue: cp} + diags.Append(objectDiags...) + if diags.HasError() { + return diags + } + state.Capacity = capacity + } else { + state.Capacity = internaltypes.ZeroObjectValue{ObjectValue: types.ObjectNull(capacityAttrs)} } + state.ConnectivityRuleIds = connectivityRuleIdsState state.Endpoints = endpointsState state.Regions = planRegionsUnordered @@ -893,6 +979,38 @@ func getLifecycleFromModel(ctx context.Context, model *namespaceResourceModel) ( }, diags } +func getCapacityFromModel(ctx context.Context, model *namespaceResourceModel) (*namespacev1.CapacitySpec, diag.Diagnostics) { + var diags diag.Diagnostics + var capacity capacityModel + diags.Append(model.Capacity.As(ctx, &capacity, basetypes.ObjectAsOptions{})...) + if diags.HasError() { + return nil, diags + } + switch capacity.Mode.ValueString() { + case "provisioned": + if capacity.Value.IsNull() || capacity.Value.ValueFloat64() <= 0 { + diags.Append(diag.NewErrorDiagnostic("Invalid capacity value", "Capacity value must be set when mode is 'provisioned'")) + return nil, diags + } + return &namespacev1.CapacitySpec{ + Spec: &namespacev1.CapacitySpec_Provisioned_{ + Provisioned: &namespacev1.CapacitySpec_Provisioned{ + Value: capacity.Value.ValueFloat64(), + }, + }, + }, diags + case "on_demand": + return &namespacev1.CapacitySpec{ + Spec: &namespacev1.CapacitySpec_OnDemand_{ + OnDemand: &namespacev1.CapacitySpec_OnDemand{}, + }, + }, diags + default: + diags.Append(diag.NewErrorDiagnostic("Invalid capacity mode", "Invalid capacity mode: "+capacity.Mode.ValueString())) + return nil, diags + } +} + func stringOrNull(s string) types.String { if s == "" { return types.StringNull() diff --git a/internal/provider/namespace_resource_test.go b/internal/provider/namespace_resource_test.go index 4eb4960..2465a52 100644 --- a/internal/provider/namespace_resource_test.go +++ b/internal/provider/namespace_resource_test.go @@ -11,10 +11,12 @@ import ( "strings" "testing" "text/template" + "time" fwresource "github.com/hashicorp/terraform-plugin-framework/resource" "github.com/hashicorp/terraform-plugin-testing/helper/resource" "github.com/hashicorp/terraform-plugin-testing/terraform" + "go.temporal.io/cloud-sdk/api/namespace/v1" cloudservicev1 "go.temporal.io/cloud-sdk/api/cloudservice/v1" @@ -791,3 +793,145 @@ PEM }, }) } + +func TestAccNamespaceWithCapacity(t *testing.T) { + name := fmt.Sprintf("%s-%s", "tf-capacity", randomString(10)) + config := func(name string, variable string) string { + return fmt.Sprintf(` +variable "provisioned" { + type = object({ + mode = string + value = number + }) + default = { + mode = "provisioned" + value = 4 + } +} + +variable "on_demand" { + type = object({ + mode = string + value = number + }) + default = { + mode = "on_demand" + value = 0 + } +} + +provider "temporalcloud" { + +} + +resource "temporalcloud_namespace" "capacitytest" { + name = "%s" + regions = ["aws-us-east-1"] + accepted_client_ca = base64encode(<