Skip to content

Commit adceee9

Browse files
becholsclaude
andauthored
Add region validation (#394)
* Add region validation during plan. Closes #265 Validates region format (aws-*/gcp-*) with ERROR on invalid format. Warns (not errors) on unknown regions since the list may be stale. Calls the GetRegions API during plan to validate configured regions Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent bb760cf commit adceee9

File tree

3 files changed

+157
-0
lines changed

3 files changed

+157
-0
lines changed

internal/provider/namespace_resource.go

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ import (
5858
namespacev1 "go.temporal.io/cloud-sdk/api/namespace/v1"
5959

6060
"github.com/temporalio/terraform-provider-temporalcloud/internal/client"
61+
"github.com/temporalio/terraform-provider-temporalcloud/internal/provider/validators"
6162
internaltypes "github.com/temporalio/terraform-provider-temporalcloud/internal/types"
6263
)
6364

@@ -121,6 +122,7 @@ var (
121122
_ resource.Resource = (*namespaceResource)(nil)
122123
_ resource.ResourceWithConfigure = (*namespaceResource)(nil)
123124
_ resource.ResourceWithImportState = (*namespaceResource)(nil)
125+
_ resource.ResourceWithModifyPlan = (*namespaceResource)(nil)
124126

125127
namespaceCertificateFilterAttrs = map[string]attr.Type{
126128
"common_name": types.StringType,
@@ -211,6 +213,9 @@ func (r *namespaceResource) Schema(ctx context.Context, _ resource.SchemaRequest
211213
CustomType: internaltypes.UnorderedStringListType{
212214
ListType: basetypes.ListType{ElemType: basetypes.StringType{}},
213215
},
216+
Validators: []validator.List{
217+
listvalidator.ValueStringsAre(validators.RegionFormat()),
218+
},
214219
},
215220
"accepted_client_ca": schema.StringAttribute{
216221
CustomType: internaltypes.EncodedCAType{},
@@ -350,6 +355,63 @@ func (r *namespaceResource) Schema(ctx context.Context, _ resource.SchemaRequest
350355
}
351356
}
352357

358+
// ModifyPlan validates configured regions against the Temporal Cloud API.
359+
func (r *namespaceResource) ModifyPlan(ctx context.Context, req resource.ModifyPlanRequest, resp *resource.ModifyPlanResponse) {
360+
// Skip on destroy.
361+
if req.Plan.Raw.IsNull() {
362+
return
363+
}
364+
365+
// Skip if the client is not configured (e.g., during early validation or provider config errors).
366+
if r.client == nil {
367+
return
368+
}
369+
370+
var plan namespaceResourceModel
371+
resp.Diagnostics.Append(req.Plan.Get(ctx, &plan)...)
372+
if resp.Diagnostics.HasError() {
373+
return
374+
}
375+
376+
// Skip if regions are unknown (computed values not yet resolved).
377+
if plan.Regions.IsUnknown() {
378+
return
379+
}
380+
381+
configuredRegions, d := getRegionsFromModel(ctx, &plan)
382+
resp.Diagnostics.Append(d...)
383+
if resp.Diagnostics.HasError() {
384+
return
385+
}
386+
387+
if len(configuredRegions) == 0 {
388+
return
389+
}
390+
391+
regionsResp, err := r.client.CloudService().GetRegions(ctx, &cloudservicev1.GetRegionsRequest{})
392+
if err != nil {
393+
resp.Diagnostics.AddWarning(
394+
"Unable to Validate Regions",
395+
fmt.Sprintf("Failed to fetch available regions from Temporal Cloud API: %s. Region validation will be skipped.", err.Error()),
396+
)
397+
return
398+
}
399+
400+
validRegions := make(map[string]bool, len(regionsResp.GetRegions()))
401+
for _, region := range regionsResp.GetRegions() {
402+
validRegions[region.GetId()] = true
403+
}
404+
405+
for _, region := range configuredRegions {
406+
if !validRegions[region] {
407+
resp.Diagnostics.AddError(
408+
"Invalid Region",
409+
fmt.Sprintf("Region %q is not a valid Temporal Cloud region. Use the temporalcloud_regions data source or see https://docs.temporal.io/cloud/regions for the list of available regions.", region),
410+
)
411+
}
412+
}
413+
}
414+
353415
// Create creates the resource and sets the initial Terraform state.
354416
func (r *namespaceResource) Create(ctx context.Context, req resource.CreateRequest, resp *resource.CreateResponse) {
355417
var plan namespaceResourceModel

internal/provider/namespace_resource_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,56 @@ PEM
163163

164164
}
165165

166+
func TestAccNamespaceInvalidRegion(t *testing.T) {
167+
config := `
168+
provider "temporalcloud" {
169+
170+
}
171+
172+
resource "temporalcloud_namespace" "terraform" {
173+
name = "tf-invalid-region"
174+
regions = ["invalid-region"]
175+
api_key_auth = true
176+
retention_days = 7
177+
}`
178+
179+
resource.ParallelTest(t, resource.TestCase{
180+
PreCheck: func() { testAccPreCheck(t) },
181+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
182+
Steps: []resource.TestStep{
183+
{
184+
Config: config,
185+
ExpectError: regexp.MustCompile(`Invalid Region Format`),
186+
},
187+
},
188+
})
189+
}
190+
191+
func TestAccNamespaceInvalidRegionAPIValidation(t *testing.T) {
192+
config := `
193+
provider "temporalcloud" {
194+
195+
}
196+
197+
resource "temporalcloud_namespace" "terraform" {
198+
name = "tf-invalid-api-region"
199+
regions = ["aws-us-fake-99"]
200+
api_key_auth = true
201+
retention_days = 7
202+
}`
203+
204+
resource.ParallelTest(t, resource.TestCase{
205+
PreCheck: func() { testAccPreCheck(t) },
206+
ProtoV6ProviderFactories: testAccProtoV6ProviderFactories,
207+
Steps: []resource.TestStep{
208+
{
209+
Config: config,
210+
ExpectError: regexp.MustCompile(`Invalid Region`),
211+
},
212+
},
213+
})
214+
}
215+
166216
func TestAccBasicNamespaceWithApiKeyAuth(t *testing.T) {
167217
name := fmt.Sprintf("%s-%s", "tf-basic-namespace", randomString(10))
168218
config := func(name string, retention int) string {
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
package validators
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"regexp"
7+
8+
"github.com/hashicorp/terraform-plugin-framework/schema/validator"
9+
)
10+
11+
var regionFormatPattern = regexp.MustCompile(`^(aws|gcp)-[a-z0-9-]+$`)
12+
13+
type regionFormatValidator struct{}
14+
15+
// RegionFormat returns a validator that checks if a string matches the expected
16+
// Temporal Cloud region format (e.g., aws-us-east-1, gcp-us-central1).
17+
// It does not verify that the region actually exists — that is handled by
18+
// ModifyPlan via the GetRegions API.
19+
func RegionFormat() validator.String {
20+
return &regionFormatValidator{}
21+
}
22+
23+
func (v *regionFormatValidator) Description(ctx context.Context) string {
24+
return "must be a valid Temporal Cloud region format (e.g., aws-us-east-1, gcp-us-central1)"
25+
}
26+
27+
func (v *regionFormatValidator) MarkdownDescription(ctx context.Context) string {
28+
return "must be a valid Temporal Cloud region format (e.g., `aws-us-east-1`, `gcp-us-central1`). See [available regions](https://docs.temporal.io/cloud/regions)."
29+
}
30+
31+
func (v *regionFormatValidator) ValidateString(ctx context.Context, req validator.StringRequest, resp *validator.StringResponse) {
32+
if req.ConfigValue.IsNull() || req.ConfigValue.IsUnknown() {
33+
return
34+
}
35+
36+
value := req.ConfigValue.ValueString()
37+
38+
if !regionFormatPattern.MatchString(value) {
39+
resp.Diagnostics.AddAttributeError(
40+
req.Path,
41+
"Invalid Region Format",
42+
fmt.Sprintf("Region %q does not match expected format. Regions must be prefixed with cloud provider (e.g., aws-us-east-1, gcp-us-central1). See https://docs.temporal.io/cloud/regions", value),
43+
)
44+
}
45+
}

0 commit comments

Comments
 (0)