diff --git a/coreweave/networking/resource_vpc.go b/coreweave/networking/resource_vpc.go index 0e7ccb06..62050a31 100644 --- a/coreweave/networking/resource_vpc.go +++ b/coreweave/networking/resource_vpc.go @@ -13,6 +13,7 @@ import ( networkingv1beta1 "buf.build/gen/go/coreweave/networking/protocolbuffers/go/coreweave/networking/v1beta1" "connectrpc.com/connect" "github.com/coreweave/terraform-provider-coreweave/coreweave" + "github.com/coreweave/terraform-provider-coreweave/internal/coretf" "github.com/hashicorp/hcl/v2/hclwrite" "github.com/hashicorp/terraform-plugin-framework-nettypes/cidrtypes" "github.com/hashicorp/terraform-plugin-framework-validators/resourcevalidator" @@ -481,6 +482,9 @@ func (r *VpcResource) Schema(ctx context.Context, req resource.SchemaRequest, re "vpc_prefixes": schema.SetNestedAttribute{ Optional: true, MarkdownDescription: "A list of additional prefixes associated with the VPC. For example, CKS clusters use these prefixes for Pod and service CIDR ranges.", + Validators: []validator.Set{ + coretf.ProtoSetValidator[VpcPrefixResourceModel](), + }, NestedObject: schema.NestedAttributeObject{ Attributes: map[string]schema.Attribute{ "name": schema.StringAttribute{ @@ -508,6 +512,7 @@ func (r *VpcResource) Schema(ctx context.Context, req resource.SchemaRequest, re Computed: true, Validators: []validator.Set{ setvalidator.SizeAtLeast(1), + coretf.ProtoSetValidator[HostPrefixResourceModel](), }, PlanModifiers: []planmodifier.Set{ setplanmodifier.RequiresReplaceIfConfigured(), @@ -551,6 +556,9 @@ func (r *VpcResource) Schema(ctx context.Context, req resource.SchemaRequest, re Optional: true, Computed: true, MarkdownDescription: "Settings affecting traffic entering the VPC.", + Validators: []validator.Object{ + coretf.ProtoValidator[VpcIngressResourceModel](), + }, Attributes: map[string]schema.Attribute{ "disable_public_services": schema.BoolAttribute{ Optional: true, @@ -567,6 +575,9 @@ func (r *VpcResource) Schema(ctx context.Context, req resource.SchemaRequest, re Optional: true, Computed: true, MarkdownDescription: "Settings affecting traffic leaving the VPC.", + Validators: []validator.Object{ + coretf.ProtoValidator[VpcEgressResourceModel](), + }, Attributes: map[string]schema.Attribute{ "disable_public_access": schema.BoolAttribute{ Optional: true, diff --git a/coreweave/networking/resource_vpc_test.go b/coreweave/networking/resource_vpc_test.go index 5613e467..007bf134 100644 --- a/coreweave/networking/resource_vpc_test.go +++ b/coreweave/networking/resource_vpc_test.go @@ -1065,3 +1065,285 @@ resource "coreweave_networking_vpc" "test_vpc" { assert.Equal(t, expected, networking.MustRenderVpcResource(ctx, resourceName, m)) }) } + +// TestVpcValidation tests validation logic without making actual API calls. +// These tests use PlanOnly mode to verify validation behavior. +func TestVpcValidation(t *testing.T) { + t.Parallel() + + t.Run("primary host prefix with IPAM should fail", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_primary_ipam_%x", randomInt) + + invalidModel := &networking.VpcResourceModel{ + Name: types.StringValue(fmt.Sprintf("test-validation-primary-ipam-%x", randomInt)), + Zone: types.StringValue("US-LAB-01A"), + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{ + { + Name: types.StringValue("primary-with-ipam"), + Type: types.StringValue(networkingv1beta1.HostPrefix_PRIMARY.String()), + Prefixes: []cidrtypes.IPPrefix{cidrtypes.NewIPPrefixValue("172.16.0.0/12")}, + IPAM: &networking.IPAMPolicyResourceModel{ + PrefixLength: types.Int32Value(64), + }, + }, + }), + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, invalidModel), + PlanOnly: true, + ExpectError: regexp.MustCompile(`(?i)host prefix ipam must not be set`), + }, + }, + }) + }) + + t.Run("attached host prefix without IPAM should fail", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_attached_no_ipam_%x", randomInt) + + invalidModel := &networking.VpcResourceModel{ + Name: types.StringValue(fmt.Sprintf("test-validation-attached-no-ipam-%x", randomInt)), + Zone: types.StringValue("US-LAB-01A"), + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{ + fixtureHostPrefixPrimary(), + { + Name: types.StringValue("attached-no-ipam"), + Type: types.StringValue(networkingv1beta1.HostPrefix_ATTACHED.String()), + Prefixes: []cidrtypes.IPPrefix{cidrtypes.NewIPPrefixValue("2601:db8:cccc::/48")}, + IPAM: nil, // Missing IPAM + }, + }), + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, invalidModel), + PlanOnly: true, + ExpectError: regexp.MustCompile(`(?i)(ipam.*required|ipam must be set)`), + }, + }, + }) + }) + + t.Run("routed host prefix without IPAM should fail", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_routed_no_ipam_%x", randomInt) + + invalidModel := &networking.VpcResourceModel{ + Name: types.StringValue(fmt.Sprintf("test-validation-routed-no-ipam-%x", randomInt)), + Zone: types.StringValue("US-LAB-01A"), + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{ + fixtureHostPrefixPrimary(), + { + Name: types.StringValue("routed-no-ipam"), + Type: types.StringValue(networkingv1beta1.HostPrefix_ROUTED.String()), + Prefixes: []cidrtypes.IPPrefix{cidrtypes.NewIPPrefixValue("2601:db8:bbbb::/48")}, + IPAM: nil, // Missing IPAM + }, + }), + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, invalidModel), + PlanOnly: true, + ExpectError: regexp.MustCompile(`(?i)(ipam.*required|ipam must be set)`), + }, + }, + }) + }) + + t.Run("invalid VPC name format should fail", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_invalid_name_%x", randomInt) + + invalidModel := &networking.VpcResourceModel{ + Name: types.StringValue("INVALID_NAME_UPPERCASE"), // Invalid format + Zone: types.StringValue("US-LAB-01A"), + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{fixtureHostPrefixPrimary()}), + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, invalidModel), + PlanOnly: true, + ExpectError: regexp.MustCompile(`(?i)(does not match regex pattern|invalid.*name)`), + }, + }, + }) + }) + + t.Run("empty vpc_prefix name should fail", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_empty_prefix_name_%x", randomInt) + + invalidModel := &networking.VpcResourceModel{ + Name: types.StringValue(fmt.Sprintf("test-validation-prefix-%x", randomInt)), + Zone: types.StringValue("US-LAB-01A"), + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{fixtureHostPrefixPrimary()}), + VpcPrefixes: []networking.VpcPrefixResourceModel{ + { + Name: types.StringValue(""), // Empty name + Value: types.StringValue("10.0.0.0/16"), + }, + }, + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, invalidModel), + PlanOnly: true, + ExpectError: regexp.MustCompile(`(?i)(name.*length must be at least 1|value length must be at least 1 characters)`), + }, + }, + }) + }) + + t.Run("invalid vpc_prefix CIDR should fail", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_invalid_cidr_%x", randomInt) + + invalidModel := &networking.VpcResourceModel{ + Name: types.StringValue(fmt.Sprintf("test-validation-cidr-%x", randomInt)), + Zone: types.StringValue("US-LAB-01A"), + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{fixtureHostPrefixPrimary()}), + VpcPrefixes: []networking.VpcPrefixResourceModel{ + { + Name: types.StringValue("invalid-cidr"), + Value: types.StringValue("not-a-valid-cidr"), // Invalid CIDR + }, + }, + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, invalidModel), + PlanOnly: true, + ExpectError: regexp.MustCompile(`(?i)(not a valid IP prefix|invalid.*cidr)`), + }, + }, + }) + }) + + t.Run("empty vpc_prefix value should fail", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_empty_prefix_value_%x", randomInt) + + invalidModel := &networking.VpcResourceModel{ + Name: types.StringValue(fmt.Sprintf("test-validation-prefix-val-%x", randomInt)), + Zone: types.StringValue("US-LAB-01A"), + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{fixtureHostPrefixPrimary()}), + VpcPrefixes: []networking.VpcPrefixResourceModel{ + { + Name: types.StringValue("empty-value"), + Value: types.StringValue(""), // Empty value + }, + }, + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, invalidModel), + PlanOnly: true, + ExpectError: regexp.MustCompile(`(?i)(value is empty|not a valid IP prefix)`), + }, + }, + }) + }) + + t.Run("host_prefix and host_prefixes both set should fail", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_both_host_prefix_%x", randomInt) + + invalidModel := &networking.VpcResourceModel{ + Name: types.StringValue(fmt.Sprintf("test-validation-both-%x", randomInt)), + Zone: types.StringValue("US-LAB-01A"), + HostPrefix: types.StringValue("172.16.0.0/12"), // Both set + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{fixtureHostPrefixPrimary()}), + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, invalidModel), + PlanOnly: true, + ExpectError: regexp.MustCompile(`(?i)These attributes cannot be configured together`), + }, + }, + }) + }) + + t.Run("valid VPC with all fields should pass", func(t *testing.T) { + randomInt := rand.IntN(100) + resourceName := fmt.Sprintf("test_validation_valid_full_%x", randomInt) + fullResourceName := fmt.Sprintf("coreweave_networking_vpc.%s", resourceName) + + validModel := &networking.VpcResourceModel{ + Name: types.StringValue(fmt.Sprintf("test-validation-valid-%x", randomInt)), + Zone: types.StringValue("US-LAB-01A"), + HostPrefixes: hostPrefixesToSet(t, []networking.HostPrefixResourceModel{ + fixtureHostPrefixPrimary(), + fixtureHostPrefixAttached(), + fixtureHostPrefixRouted(), + }), + VpcPrefixes: []networking.VpcPrefixResourceModel{ + { + Name: types.StringValue("pod-cidr"), + Value: types.StringValue("10.0.0.0/16"), + }, + { + Name: types.StringValue("service-cidr"), + Value: types.StringValue("10.16.0.0/16"), + }, + }, + Ingress: &networking.VpcIngressResourceModel{ + DisablePublicServices: types.BoolValue(false), + }, + Egress: &networking.VpcEgressResourceModel{ + DisablePublicAccess: types.BoolValue(false), + }, + Dhcp: &networking.VpcDhcpResourceModel{ + Dns: &networking.VpcDhcpDnsResourceModel{ + Servers: types.SetValueMust(types.StringType, []attr.Value{ + types.StringValue("1.1.1.1"), + types.StringValue("8.8.8.8"), + }), + }, + }, + } + + resource.ParallelTest(t, resource.TestCase{ + ProtoV6ProviderFactories: provider.TestProtoV6ProviderFactories, + Steps: []resource.TestStep{ + { + Config: networking.MustRenderVpcResource(t.Context(), resourceName, validModel), + PlanOnly: true, + ExpectNonEmptyPlan: true, + ConfigPlanChecks: resource.ConfigPlanChecks{ + PostApplyPreRefresh: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction(fullResourceName, plancheck.ResourceActionCreate), + }, + }, + }, + }, + }) + }) +} diff --git a/go.mod b/go.mod index 1b292e0f..bd60864b 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( buf.build/gen/go/coreweave/cwobject/protocolbuffers/go v1.36.10-20250604181649-b97f17b05d5b.1 buf.build/gen/go/coreweave/networking/connectrpc/go v1.19.1-20260121155637-a637e7777165.2 buf.build/gen/go/coreweave/networking/protocolbuffers/go v1.36.11-20260121155637-a637e7777165.1 + buf.build/go/protovalidate v1.1.0 connectrpc.com/connect v1.19.1 github.com/aws/aws-sdk-go-v2 v1.37.0 github.com/aws/aws-sdk-go-v2/config v1.30.1 @@ -35,8 +36,10 @@ require ( require ( buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.36.11-20251209175733-2a1774d88802.1 // indirect buf.build/gen/go/grpc-ecosystem/grpc-gateway/protocolbuffers/go v1.36.10-20241220201140-4c5ba75caaf8.1 // indirect + cel.dev/expr v0.24.0 // indirect github.com/ProtonMail/go-crypto v1.1.6 // indirect github.com/agext/levenshtein v1.2.2 // indirect + github.com/antlr4-go/antlr/v4 v4.13.1 // indirect github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.0 // indirect github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.0 // indirect @@ -55,6 +58,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/fatih/color v1.16.0 // indirect github.com/golang/protobuf v1.5.4 // indirect + github.com/google/cel-go v0.26.1 // indirect github.com/google/go-cmp v0.7.0 // indirect github.com/hashicorp/errwrap v1.1.0 // indirect github.com/hashicorp/go-checkpoint v0.5.0 // indirect @@ -70,7 +74,6 @@ require ( github.com/hashicorp/terraform-registry-address v0.4.0 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.2 // indirect - github.com/kr/text v0.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/mitchellh/copystructure v1.2.0 // indirect @@ -80,10 +83,12 @@ require ( github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/oklog/run v1.1.0 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/stoewer/go-strcase v1.3.1 // indirect github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/crypto v0.47.0 // indirect + golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 // indirect golang.org/x/mod v0.32.0 // indirect golang.org/x/net v0.49.0 // indirect golang.org/x/sync v0.19.0 // indirect diff --git a/go.sum b/go.sum index d8782f2a..c991cdfc 100644 --- a/go.sum +++ b/go.sum @@ -14,6 +14,10 @@ buf.build/gen/go/coreweave/networking/protocolbuffers/go v1.36.11-20260121155637 buf.build/gen/go/coreweave/networking/protocolbuffers/go v1.36.11-20260121155637-a637e7777165.1/go.mod h1:dv5LhRYj3xPrMzvA33dyS2aWx4atitDIeGOz+kPGKwA= buf.build/gen/go/grpc-ecosystem/grpc-gateway/protocolbuffers/go v1.36.10-20241220201140-4c5ba75caaf8.1 h1:wTGP8mCVfUD0L8WSKtEs3g0MtlHszhk7Iic+HLXkq8o= buf.build/gen/go/grpc-ecosystem/grpc-gateway/protocolbuffers/go v1.36.10-20241220201140-4c5ba75caaf8.1/go.mod h1:Dkpdum4mgWWBTG5nDVJPCDdaqFh34tucP6RulxhdOjs= +buf.build/go/protovalidate v1.1.0 h1:pQqEQRpOo4SqS60qkvmhLTTQU9JwzEvdyiqAtXa5SeY= +buf.build/go/protovalidate v1.1.0/go.mod h1:bGZcPiAQDC3ErCHK3t74jSoJDFOs2JH3d7LWuTEIdss= +cel.dev/expr v0.24.0 h1:56OvJKSH3hDGL0ml5uSxZmz3/3Pq4tJ+fb1unVLAFcY= +cel.dev/expr v0.24.0/go.mod h1:hLPLo1W4QUmuYdA72RBX06QTs6MXw941piREPl3Yfiw= connectrpc.com/connect v1.19.1 h1:R5M57z05+90EfEvCY1b7hBxDVOUl45PrtXtAV2fOC14= connectrpc.com/connect v1.19.1/go.mod h1:tN20fjdGlewnSFeZxLKb0xwIZ6ozc3OQs2hTXy4du9w= dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk= @@ -24,6 +28,8 @@ github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNx github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/agext/levenshtein v1.2.2 h1:0S/Yg6LYmFJ5stwQeRp6EeOcCbj7xiqQSdNelsXvaqE= github.com/agext/levenshtein v1.2.2/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= +github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ= +github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw= github.com/apparentlymart/go-textseg/v12 v12.0.0/go.mod h1:S/4uRK2UtaQttw1GenVJEynmyUenKwP++x/+DdGV/Ec= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= @@ -63,11 +69,12 @@ github.com/aws/aws-sdk-go-v2/service/sts v1.35.0 h1:FD9agdG4CeOGS3ORLByJk56YIXDS github.com/aws/aws-sdk-go-v2/service/sts v1.35.0/go.mod h1:NDzDPbBF1xtSTZUMuZx0w3hIfWzcL7X2AQ0Tr9becIQ= github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk= github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0= +github.com/brianvoe/gofakeit/v6 v6.28.0 h1:Xib46XXuQfmlLS2EXRuJpqcw8St6qSZz75OUo0tgAW4= +github.com/brianvoe/gofakeit/v6 v6.28.0/go.mod h1:Xj58BMSnFqcn/fAQeSK+/PLtC5kSb7FJIq4JyGa8vEs= github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw= github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c= github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -98,6 +105,8 @@ github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaS github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/cel-go v0.26.1 h1:iPbVVEdkhTX++hpe3lzSk7D3G3QSYqLGoHOcEio+UXQ= +github.com/google/cel-go v0.26.1/go.mod h1:A9O8OU9rdvrK5MQyrqfIxo1a0u4g3sF8KB6PUIaryMM= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= @@ -196,14 +205,23 @@ github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxu github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/rodaine/protogofakeit v0.1.1 h1:ZKouljuRM3A+TArppfBqnH8tGZHOwM/pjvtXe9DaXH8= +github.com/rodaine/protogofakeit v0.1.1/go.mod h1:pXn/AstBYMaSfc1/RqH3N82pBuxtWgejz1AlYpY1mI0= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/stoewer/go-strcase v1.3.1 h1:iS0MdW+kVTxgMoE1LAZyMiYJFKlOzLooE4MxjirtkAs= +github.com/stoewer/go-strcase v1.3.1/go.mod h1:fAH5hQ5pehh+j3nZfvwdk2RgEgQjAoM8wodgtPmh1xo= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/vmihailenco/msgpack v3.3.3+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= @@ -236,6 +254,8 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6 h1:SbTAbRFnd5kjQXbczszQ0hdk3ctwYf3qBNH9jIsGclE= +golang.org/x/exp v0.0.0-20250813145105-42675adae3e6/go.mod h1:4QTo5u+SEIbbKW1RacMZq1YEfOBqeXa19JeshGi+zc4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c= golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU= @@ -300,5 +320,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME= gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/coretf/protovalidate.go b/internal/coretf/protovalidate.go new file mode 100644 index 00000000..af137b1a --- /dev/null +++ b/internal/coretf/protovalidate.go @@ -0,0 +1,437 @@ +package coretf + +import ( + "context" + "fmt" + "reflect" + + "buf.build/go/protovalidate" + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types/basetypes" + "google.golang.org/protobuf/proto" +) + +// Protoable is an interface for types that can be converted to proto messages. +// Models can implement any of these signatures: +// - ToProto() *proto.Message +// - ToProto() (*proto.Message, diag.Diagnostics) +// - ToProto(context.Context) (*proto.Message, diag.Diagnostics) +type Protoable interface { + any +} + +// objectValidator validates single nested attributes by converting them to proto messages. +type objectValidator[T Protoable] struct { + description string +} + +// ProtoValidator creates a validator for single nested attributes. +// The model type T must have a ToProto method with one of these signatures: +// - ToProto() *proto.Message +// - ToProto() (*proto.Message, diag.Diagnostics) +// - ToProto(context.Context) (*proto.Message, diag.Diagnostics) +// +// Example usage: +// +// coretf.ProtoValidator[VpcIngressResourceModel]() +func ProtoValidator[T Protoable]() validator.Object { + return &objectValidator[T]{ + description: fmt.Sprintf("validates %T using protovalidate", *new(T)), + } +} + +func (v *objectValidator[T]) Description(ctx context.Context) string { + return v.description +} + +func (v *objectValidator[T]) MarkdownDescription(ctx context.Context) string { + return v.description +} + +func (v *objectValidator[T]) ValidateObject(ctx context.Context, req validator.ObjectRequest, resp *validator.ObjectResponse) { + // Skip validation for unknown or null values + if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { + return + } + + // Check if object has any unknown nested values before trying to extract + // This prevents "target type cannot handle unknown values" errors when + // the model has pointer fields that can't represent unknown state + objValue, diags := req.ConfigValue.ToObjectValue(ctx) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + if hasUnknownInObject(objValue) { + // Skip validation for objects with unknown nested values + return + } + + // Object is fully known - safe to extract to struct + var model T + diags = objValue.As(ctx, &model, basetypes.ObjectAsOptions{}) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + // Convert to proto message using reflection to find and call ToProto method + protoMsg, diags := callToProto(ctx, &model) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + return + } + + // Skip validation if conversion returned nil (model couldn't be fully converted) + if protoMsg == nil { + return + } + + // Validate the proto message + validator, err := protovalidate.New() + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Protovalidate initialization failed", + fmt.Sprintf("Failed to create protovalidate validator: %s", err.Error()), + ) + return + } + + if err := validator.Validate(protoMsg); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Validation failed", + formatProtoValidateError(err), + ) + } +} + +// setValidator validates set attributes by converting each element to a proto message. +type setValidator[T Protoable] struct { + description string +} + +// ProtoSetValidator creates a validator for set attributes. +// The model type T must have a ToProto method with one of these signatures: +// - ToProto() *proto.Message +// - ToProto() (*proto.Message, diag.Diagnostics) +// - ToProto(context.Context) (*proto.Message, diag.Diagnostics) +// +// Example usage: +// +// coretf.ProtoSetValidator[VpcPrefixResourceModel]() +func ProtoSetValidator[T Protoable]() validator.Set { + return &setValidator[T]{ + description: fmt.Sprintf("validates each %T element using protovalidate", *new(T)), + } +} + +func (v *setValidator[T]) Description(ctx context.Context) string { + return v.description +} + +func (v *setValidator[T]) MarkdownDescription(ctx context.Context) string { + return v.description +} + +func (v *setValidator[T]) ValidateSet(ctx context.Context, req validator.SetRequest, resp *validator.SetResponse) { + // Skip validation for unknown or null values + if req.ConfigValue.IsUnknown() || req.ConfigValue.IsNull() { + return + } + + // Get raw elements to handle unknown nested values + // We MUST check for unknowns BEFORE calling ElementsAs() because: + // - Pointer fields (*NestedModel) cannot handle unknown values + // - ElementsAs() will fail with "target type cannot handle unknown values" + // - We need to skip validation for elements with unknowns, not fail + elements := req.ConfigValue.Elements() + + // Create protovalidate validator once for all elements + validator, err := protovalidate.New() + if err != nil { + resp.Diagnostics.AddAttributeError( + req.Path, + "Protovalidate initialization failed", + fmt.Sprintf("Failed to create protovalidate validator: %s", err.Error()), + ) + return + } + + // Validate each element independently + for i, elem := range elements { + // Check if element is unknown or null + if elem.IsNull() { + continue + } + if unknownable, ok := elem.(interface{ IsUnknown() bool }); ok && unknownable.IsUnknown() { + continue + } + + // Cast element to types.Object (set elements are objects for nested attributes) + objVal, ok := elem.(basetypes.ObjectValuable) + if !ok { + // Element is not an object - might be a simple type + // This shouldn't happen for nested attributes but handle gracefully + continue + } + + objValue, diags := objVal.ToObjectValue(ctx) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + // Check if object has any unknown nested values + if hasUnknownInObject(objValue) { + // Skip validation for this element - has unknown nested values + continue + } + + // Object is fully known - safe to extract to struct + var model T + diags = objValue.As(ctx, &model, basetypes.ObjectAsOptions{}) + if diags.HasError() { + resp.Diagnostics.Append(diags...) + continue + } + + // Convert to proto message using reflection + protoMsg, diags := callToProto(ctx, &model) + resp.Diagnostics.Append(diags...) + if diags.HasError() { + continue + } + + // Skip if conversion returned nil + if protoMsg == nil { + continue + } + + // Validate the proto message + if err := validator.Validate(protoMsg); err != nil { + resp.Diagnostics.AddAttributeError( + req.Path.AtSetValue(elem), + fmt.Sprintf("Validation failed for element %d", i), + formatProtoValidateError(err), + ) + } + } +} + +// hasUnknownInObject recursively checks if a types.Object has any unknown values. +// This works with Terraform Framework types before they're extracted to structs. +func hasUnknownInObject(obj basetypes.ObjectValue) bool { + if obj.IsUnknown() { + return true + } + + attrs := obj.Attributes() + for _, attrVal := range attrs { + if hasUnknownInValue(attrVal) { + return true + } + } + + return false +} + +// hasUnknownInValue checks if an attr.Value (or nested structures) is unknown. +// This recursively checks all Terraform Framework types (Object, List, Set, Map, etc.) +func hasUnknownInValue(val attr.Value) bool { + // Check if value is null first + if val.IsNull() { + return false + } + + // Check if value has IsUnknown method + if unknownable, ok := val.(interface{ IsUnknown() bool }); ok { + if unknownable.IsUnknown() { + return true + } + } + + // Recursively check nested objects + if objValuable, ok := val.(basetypes.ObjectValuable); ok { + objValue, diags := objValuable.ToObjectValue(context.Background()) + if diags.HasError() { + // If we can't convert, assume it might be unknown + return true + } + return hasUnknownInObject(objValue) + } + + // Check lists + if listValuable, ok := val.(basetypes.ListValuable); ok { + listValue, diags := listValuable.ToListValue(context.Background()) + if diags.HasError() { + return true + } + if listValue.IsUnknown() { + return true + } + for _, elem := range listValue.Elements() { + if hasUnknownInValue(elem) { + return true + } + } + } + + // Check sets + if setValuable, ok := val.(basetypes.SetValuable); ok { + setValue, diags := setValuable.ToSetValue(context.Background()) + if diags.HasError() { + return true + } + if setValue.IsUnknown() { + return true + } + for _, elem := range setValue.Elements() { + if hasUnknownInValue(elem) { + return true + } + } + } + + // Check maps + if mapValuable, ok := val.(basetypes.MapValuable); ok { + mapValue, diags := mapValuable.ToMapValue(context.Background()) + if diags.HasError() { + return true + } + if mapValue.IsUnknown() { + return true + } + for _, elem := range mapValue.Elements() { + if hasUnknownInValue(elem) { + return true + } + } + } + + return false +} + +// callToProto uses reflection to call the ToProto method on a model. +// It supports three signatures: +// - ToProto() *proto.Message +// - ToProto() (*proto.Message, diag.Diagnostics) +// - ToProto(context.Context) (*proto.Message, diag.Diagnostics) +func callToProto(ctx context.Context, model any) (proto.Message, diag.Diagnostics) { + var diagnostics diag.Diagnostics + + val := reflect.ValueOf(model) + method := val.MethodByName("ToProto") + if !method.IsValid() { + diagnostics.AddError( + "ToProto method not found", + fmt.Sprintf("Type %T does not have a ToProto method", model), + ) + return nil, diagnostics + } + + methodType := method.Type() + numIn := methodType.NumIn() + numOut := methodType.NumOut() + + var results []reflect.Value + + // Call ToProto based on its signature + switch { + case numIn == 0 && numOut == 1: + // ToProto() *proto.Message + results = method.Call(nil) + + case numIn == 0 && numOut == 2: + // ToProto() (*proto.Message, diag.Diagnostics) + results = method.Call(nil) + + case numIn == 1 && numOut == 2: + // ToProto(context.Context) (*proto.Message, diag.Diagnostics) + results = method.Call([]reflect.Value{reflect.ValueOf(ctx)}) + + default: + diagnostics.AddError( + "Unsupported ToProto signature", + fmt.Sprintf("Type %T has an unsupported ToProto signature with %d inputs and %d outputs", model, numIn, numOut), + ) + return nil, diagnostics + } + + // Extract proto message from first return value + var protoMsg proto.Message + if len(results) > 0 && !results[0].IsNil() { + protoMsg = results[0].Interface().(proto.Message) + } + + // Extract diagnostics from second return value if present + if len(results) > 1 { + if diagsVal, ok := results[1].Interface().(diag.Diagnostics); ok { + diagnostics.Append(diagsVal...) + } + } + + return protoMsg, diagnostics +} + +// formatProtoValidateError formats protovalidate errors for user consumption. +func formatProtoValidateError(err error) string { + // For now, return the error as-is + // TODO: Parse and reformat proto field paths to Terraform paths + return err.Error() +} + +// ValidateProtoMessage validates a proto message directly using protovalidate. +// This is useful for resource-level ValidateConfig methods. +func ValidateProtoMessage(protoMsg proto.Message) diag.Diagnostics { + var diagnostics diag.Diagnostics + + validator, err := protovalidate.New() + if err != nil { + diagnostics.AddError( + "Protovalidate initialization failed", + fmt.Sprintf("Failed to create protovalidate validator: %s", err.Error()), + ) + return diagnostics + } + + if err := validator.Validate(protoMsg); err != nil { + diagnostics.AddError( + "Validation failed", + formatProtoValidateError(err), + ) + } + + return diagnostics +} + +// ValidateProtoAtPath validates a proto message and adds errors at a specific path. +// This is useful for resource-level ValidateConfig methods. +func ValidateProtoAtPath(protoMsg proto.Message, attrPath path.Path) diag.Diagnostics { + var diagnostics diag.Diagnostics + + validator, err := protovalidate.New() + if err != nil { + diagnostics.AddAttributeError( + attrPath, + "Protovalidate initialization failed", + fmt.Sprintf("Failed to create protovalidate validator: %s", err.Error()), + ) + return diagnostics + } + + if err := validator.Validate(protoMsg); err != nil { + diagnostics.AddAttributeError( + attrPath, + "Validation failed", + formatProtoValidateError(err), + ) + } + + return diagnostics +} diff --git a/internal/coretf/protovalidate_test.go b/internal/coretf/protovalidate_test.go new file mode 100644 index 00000000..368a31a5 --- /dev/null +++ b/internal/coretf/protovalidate_test.go @@ -0,0 +1,256 @@ +package coretf + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/diag" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// TestModel is a simple model for testing +type TestModel struct { + Value types.String `tfsdk:"value"` +} + +// ToProto implements the simple ToProto signature +func (m *TestModel) ToProto() *wrapperspb.StringValue { + if m.Value.IsNull() || m.Value.IsUnknown() { + return nil + } + return wrapperspb.String(m.Value.ValueString()) +} + +// TestProtoValidator_SkipsUnknown verifies that validators skip unknown values +func TestProtoValidator_SkipsUnknown(t *testing.T) { + t.Parallel() + + ctx := context.Background() + v := ProtoValidator[TestModel]() + + // Create an object with an unknown field + obj := types.ObjectValueMust( + map[string]attr.Type{ + "value": types.StringType, + }, + map[string]attr.Value{ + "value": types.StringUnknown(), + }, + ) + + req := validator.ObjectRequest{ + Path: path.Root("test"), + ConfigValue: obj, + } + resp := &validator.ObjectResponse{} + + v.ValidateObject(ctx, req, resp) + + // Should have no diagnostics (validation was skipped) + assert.False(t, resp.Diagnostics.HasError(), "Should not have errors for unknown values") +} + +// TestProtoValidator_ValidatesKnown verifies that validators validate known values +func TestProtoValidator_ValidatesKnown(t *testing.T) { + t.Parallel() + + ctx := context.Background() + v := ProtoValidator[TestModel]() + + // Create an object with a known field + obj := types.ObjectValueMust( + map[string]attr.Type{ + "value": types.StringType, + }, + map[string]attr.Value{ + "value": types.StringValue("test"), + }, + ) + + req := validator.ObjectRequest{ + Path: path.Root("test"), + ConfigValue: obj, + } + resp := &validator.ObjectResponse{} + + v.ValidateObject(ctx, req, resp) + + // Should have no diagnostics (valid proto) + assert.False(t, resp.Diagnostics.HasError(), "Should not have errors for valid values") +} + +// TestProtoValidator_SkipsNull verifies that validators skip null values +func TestProtoValidator_SkipsNull(t *testing.T) { + t.Parallel() + + ctx := context.Background() + v := ProtoValidator[TestModel]() + + req := validator.ObjectRequest{ + Path: path.Root("test"), + ConfigValue: types.ObjectNull(map[string]attr.Type{"value": types.StringType}), + } + resp := &validator.ObjectResponse{} + + v.ValidateObject(ctx, req, resp) + + // Should have no diagnostics (validation was skipped) + assert.False(t, resp.Diagnostics.HasError(), "Should not have errors for null values") +} + +// TestSetModel is a simple model for testing set validators +type TestSetModel struct { + Name types.String `tfsdk:"name"` +} + +// ToProto implements ToProto with diagnostics +func (m *TestSetModel) ToProto() (*wrapperspb.StringValue, diag.Diagnostics) { + var diagnostics diag.Diagnostics + + if m.Name.IsNull() || m.Name.IsUnknown() { + return nil, diagnostics + } + + if m.Name.ValueString() == "" { + diagnostics.AddError("Invalid name", "Name cannot be empty") + return nil, diagnostics + } + + return wrapperspb.String(m.Name.ValueString()), diagnostics +} + +// TestProtoSetValidator_SkipsUnknownElements verifies that set validators skip unknown elements +func TestProtoSetValidator_SkipsUnknownElements(t *testing.T) { + t.Parallel() + + ctx := context.Background() + v := ProtoSetValidator[TestSetModel]() + + // Create a set with mixed known and unknown elements + objType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + }, + } + + knownElem := types.ObjectValueMust( + objType.AttrTypes, + map[string]attr.Value{ + "name": types.StringValue("known"), + }, + ) + + unknownElem := types.ObjectValueMust( + objType.AttrTypes, + map[string]attr.Value{ + "name": types.StringUnknown(), + }, + ) + + set := types.SetValueMust(objType, []attr.Value{knownElem, unknownElem}) + + req := validator.SetRequest{ + Path: path.Root("test"), + ConfigValue: set, + } + resp := &validator.SetResponse{} + + v.ValidateSet(ctx, req, resp) + + // Should have no diagnostics (unknown element skipped, known element valid) + assert.False(t, resp.Diagnostics.HasError(), "Should not have errors when unknown elements are skipped") +} + +// TestCallToProto_SimpleSignature tests calling ToProto with no args, no diagnostics +func TestCallToProto_SimpleSignature(t *testing.T) { + t.Parallel() + + ctx := context.Background() + model := &TestModel{Value: types.StringValue("test")} + + protoMsg, diags := callToProto(ctx, model) + + assert.False(t, diags.HasError(), "Should not have errors") + assert.NotNil(t, protoMsg, "Should return proto message") + + stringVal, ok := protoMsg.(*wrapperspb.StringValue) + assert.True(t, ok, "Should be StringValue") + assert.Equal(t, "test", stringVal.Value, "Should have correct value") +} + +// TestCallToProto_WithDiagnostics tests calling ToProto that returns diagnostics +func TestCallToProto_WithDiagnostics(t *testing.T) { + t.Parallel() + + ctx := context.Background() + model := &TestSetModel{Name: types.StringValue("test")} + + protoMsg, diags := callToProto(ctx, model) + + assert.False(t, diags.HasError(), "Should not have errors for valid value") + assert.NotNil(t, protoMsg, "Should return proto message") +} + +// TestCallToProto_InvalidValue tests calling ToProto with invalid value +func TestCallToProto_InvalidValue(t *testing.T) { + t.Parallel() + + ctx := context.Background() + model := &TestSetModel{Name: types.StringValue("")} + + protoMsg, diags := callToProto(ctx, model) + + assert.True(t, diags.HasError(), "Should have error for empty name") + assert.Nil(t, protoMsg, "Should return nil proto message on error") +} + +// TestHasUnknownInObject tests the unknown field detection using Framework types +func TestHasUnknownInObject(t *testing.T) { + t.Parallel() + + t.Run("known fields", func(t *testing.T) { + obj := types.ObjectValueMust( + map[string]attr.Type{"value": types.StringType}, + map[string]attr.Value{"value": types.StringValue("test")}, + ) + assert.False(t, hasUnknownInObject(obj), "Should return false for known fields") + }) + + t.Run("unknown fields", func(t *testing.T) { + obj := types.ObjectValueMust( + map[string]attr.Type{"value": types.StringType}, + map[string]attr.Value{"value": types.StringUnknown()}, + ) + assert.True(t, hasUnknownInObject(obj), "Should return true for unknown fields") + }) + + t.Run("null fields", func(t *testing.T) { + obj := types.ObjectValueMust( + map[string]attr.Type{"value": types.StringType}, + map[string]attr.Value{"value": types.StringNull()}, + ) + assert.False(t, hasUnknownInObject(obj), "Should return false for null fields") + }) + + t.Run("nested unknown fields", func(t *testing.T) { + nestedType := types.ObjectType{ + AttrTypes: map[string]attr.Type{"inner": types.StringType}, + } + obj := types.ObjectValueMust( + map[string]attr.Type{ + "name": types.StringType, + "nested": nestedType, + }, + map[string]attr.Value{ + "name": types.StringValue("test"), + "nested": types.ObjectUnknown(nestedType.AttrTypes), + }, + ) + assert.True(t, hasUnknownInObject(obj), "Should return true for nested unknown object") + }) +} diff --git a/internal/coretf/protovalidate_unknown_nested_test.go b/internal/coretf/protovalidate_unknown_nested_test.go new file mode 100644 index 00000000..46f3b591 --- /dev/null +++ b/internal/coretf/protovalidate_unknown_nested_test.go @@ -0,0 +1,196 @@ +package coretf + +import ( + "context" + "testing" + + "github.com/hashicorp/terraform-plugin-framework/attr" + "github.com/hashicorp/terraform-plugin-framework/path" + "github.com/hashicorp/terraform-plugin-framework/schema/validator" + "github.com/hashicorp/terraform-plugin-framework/types" + "github.com/stretchr/testify/assert" + "google.golang.org/protobuf/types/known/wrapperspb" +) + +// NestedModel demonstrates the issue with pointer fields that can't handle unknowns +type NestedModel struct { + Value types.String `tfsdk:"value"` +} + +// ParentModel with pointer to nested struct - CANNOT handle unknown nested values +type ParentModelWithPointer struct { + Name types.String `tfsdk:"name"` + Nested *NestedModel `tfsdk:"nested"` // Pointer - cannot handle +} + +// ToProto implements ToProto for testing (returns a simple wrapper) +func (m *ParentModelWithPointer) ToProto() *wrapperspb.StringValue { + if m.Name.IsNull() || m.Name.IsUnknown() { + return nil + } + return wrapperspb.String(m.Name.ValueString()) +} + +// ParentModel with types.Object - CAN handle unknown nested values +type ParentModelWithObject struct { + Name types.String `tfsdk:"name"` + Nested types.Object `tfsdk:"nested"` // Object - can handle +} + +// TestSetValidator_UnknownNestedPointer demonstrates the issue +// This test shows that when a set element has a nested unknown value, +// ElementsAs fails if the model uses a pointer for the nested field. +func TestSetValidator_UnknownNestedPointer(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create object type for nested field + nestedObjType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "value": types.StringType, + }, + } + + // Create set element with UNKNOWN nested object + // This is what Terraform creates when you do: nested = var.unknown_var + elem := types.ObjectValueMust( + map[string]attr.Type{ + "name": types.StringType, + "nested": nestedObjType, + }, + map[string]attr.Value{ + "name": types.StringValue("test"), + "nested": types.ObjectUnknown(nestedObjType.AttrTypes), // <-- UNKNOWN nested object + }, + ) + + // Create a set containing this element + setObjType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "nested": nestedObjType, + }, + } + set := types.SetValueMust(setObjType, []attr.Value{elem}) + + // Try to extract elements into []ParentModelWithPointer + // This WILL FAIL because *NestedModel cannot handle + var models []ParentModelWithPointer + diags := set.ElementsAs(ctx, &models, false) + + // This is the error we're seeing + assert.True(t, diags.HasError(), "ElementsAs should fail when nested field is unknown and uses pointer") + + // The error message will say something like: + // "Received unknown value, however the target type cannot handle unknown values" + // "Target Type: *NestedModel" + // "Suggested Type: basetypes.ObjectValue" + if diags.HasError() { + t.Logf("Expected error: %v", diags.Errors()[0].Detail()) + } +} + +// TestSetValidator_UnknownNestedObject shows the solution +// Using types.Object instead of pointer allows handling unknown values +func TestSetValidator_UnknownNestedObject(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create object type for nested field + nestedObjType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "value": types.StringType, + }, + } + + // Create set element with UNKNOWN nested object + elem := types.ObjectValueMust( + map[string]attr.Type{ + "name": types.StringType, + "nested": nestedObjType, + }, + map[string]attr.Value{ + "name": types.StringValue("test"), + "nested": types.ObjectUnknown(nestedObjType.AttrTypes), // <-- UNKNOWN nested object + }, + ) + + // Create a set containing this element + setObjType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "nested": nestedObjType, + }, + } + set := types.SetValueMust(setObjType, []attr.Value{elem}) + + // Try to extract elements into []ParentModelWithObject + // This WILL SUCCEED because types.Object CAN handle + var models []ParentModelWithObject + diags := set.ElementsAs(ctx, &models, false) + + assert.False(t, diags.HasError(), "ElementsAs should succeed when nested field uses types.Object") + assert.Len(t, models, 1, "Should extract one element") + assert.Equal(t, "test", models[0].Name.ValueString(), "Name should be extracted") + assert.True(t, models[0].Nested.IsUnknown(), "Nested should be unknown") +} + +// TestProtoSetValidator_HandlesUnknownNestedPointer tests if validator handles unknown nested pointer fields +// This test verifies that the validator properly skips validation when elements have unknown nested values, +// even when the model uses pointer fields that can't represent unknown state. +func TestProtoSetValidator_HandlesUnknownNestedPointer(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // Create a validator for ParentModelWithPointer + // This model has a pointer field (*NestedModel) that CANNOT handle unknown values + v := ProtoSetValidator[ParentModelWithPointer]() + + // Create object type for nested field + nestedObjType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "value": types.StringType, + }, + } + + // Create set element with UNKNOWN nested object + // This is what Terraform creates when you do: nested = var.unknown_var + elem := types.ObjectValueMust( + map[string]attr.Type{ + "name": types.StringType, + "nested": nestedObjType, + }, + map[string]attr.Value{ + "name": types.StringValue("test"), + "nested": types.ObjectUnknown(nestedObjType.AttrTypes), // <-- UNKNOWN nested object + }, + ) + + setObjType := types.ObjectType{ + AttrTypes: map[string]attr.Type{ + "name": types.StringType, + "nested": nestedObjType, + }, + } + set := types.SetValueMust(setObjType, []attr.Value{elem}) + + req := validator.SetRequest{ + Path: path.Root("test"), + ConfigValue: set, + } + resp := &validator.SetResponse{} + + // Run the validator + // With our fix, this should: + // 1. Detect that the element has an unknown nested value + // 2. Skip validation (return without error) + // 3. NOT call ElementsAs (which would fail with "target type cannot handle unknown values") + v.ValidateSet(ctx, req, resp) + + // Should have no errors - validation was properly skipped + assert.False(t, resp.Diagnostics.HasError(), + "Should not error when element has unknown nested pointer field") +}