From 6e8402b5718c52fb2e646a204c5f08927edf79b0 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 14:13:06 -0400 Subject: [PATCH 01/32] Update `terraform-plugin-go` dependency --- go.mod | 14 +++++++------- go.sum | 8 ++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 44908097191..fc88b9544ba 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/hashicorp/terraform-plugin-sdk/v2 -go 1.21 +go 1.22 -toolchain go1.21.6 +toolchain go1.22.6 require ( github.com/google/go-cmp v0.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/go-hclog v1.6.3 - github.com/hashicorp/go-plugin v1.6.0 + github.com/hashicorp/go-plugin v1.6.1 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/hc-install v0.8.0 @@ -16,7 +16,7 @@ require ( github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.22.1 - github.com/hashicorp/terraform-plugin-go v0.23.0 + github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/go-testing-interface v1.14.1 @@ -55,7 +55,7 @@ require ( golang.org/x/text v0.17.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect - google.golang.org/grpc v1.63.2 // indirect - google.golang.org/protobuf v1.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/grpc v1.65.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/go.sum b/go.sum index c56ff86b7f5..07f42a50a96 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,7 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -76,6 +77,10 @@ github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7 github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= +github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240827184608-0af265897829 h1:VX0f1Nh8XdurAWeN6ea7AzrVCO0mZQDPTEQdDKbDyTM= +github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240827184608-0af265897829/go.mod h1:ko0HcPe7AkwMukddKa13Qq5zyvi8V9KYxBZmqU8a9cE= +github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def h1:/RKsl9EoVaSGf4PgyuDEmnPd2f/x2jdntwx+q0kY2xA= +github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def/go.mod h1:ko0HcPe7AkwMukddKa13Qq5zyvi8V9KYxBZmqU8a9cE= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= @@ -196,12 +201,15 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From d30854f3f0bec11a629d43cf94eb2b5192b73884 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 14:16:45 -0400 Subject: [PATCH 02/32] Add `WriteOnly` attribute to schema and internal schema validation. --- helper/schema/core_schema.go | 1 + helper/schema/core_schema_test.go | 19 + helper/schema/schema.go | 51 +++ helper/schema/schema_test.go | 585 +++++++++++++++++++++++- internal/configs/configschema/schema.go | 11 + 5 files changed, 666 insertions(+), 1 deletion(-) diff --git a/helper/schema/core_schema.go b/helper/schema/core_schema.go index 736af218da2..3f12fd3b6dd 100644 --- a/helper/schema/core_schema.go +++ b/helper/schema/core_schema.go @@ -167,6 +167,7 @@ func (s *Schema) coreConfigSchemaAttribute() *configschema.Attribute { Description: desc, DescriptionKind: descKind, Deprecated: s.Deprecated != "", + WriteOnly: s.WriteOnly, } } diff --git a/helper/schema/core_schema_test.go b/helper/schema/core_schema_test.go index b8362f15ec1..76fcfa8b945 100644 --- a/helper/schema/core_schema_test.go +++ b/helper/schema/core_schema_test.go @@ -458,6 +458,25 @@ func TestSchemaMapCoreConfigSchema(t *testing.T) { BlockTypes: map[string]*configschema.NestedBlock{}, }), }, + "write-only": { + map[string]*Schema{ + "string": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + testResource(&configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "string": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{}, + }), + }, } for name, test := range tests { diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 176288b0cd8..cfb85020b2c 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -395,6 +395,17 @@ type Schema struct { // as sensitive. Any outputs containing a sensitive value must enable the // output sensitive argument. Sensitive bool + + // WriteOnly indicates that the practitioner can choose a value for this + // attribute, but Terraform will not store this attribute in state. + // If WriteOnly is true, either Optional or Required must also be true. + // + // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. + // + // This functionality is only supported in Terraform 1.XX and later. TODO: Add Terraform version + // Practitioners that choose a value for this attribute with older + // versions of Terraform will receive an error. + WriteOnly bool } // SchemaConfigMode is used to influence how a schema item is mapped into a @@ -838,6 +849,14 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro return fmt.Errorf("%s: One of optional, required, or computed must be set", k) } + if v.WriteOnly && !(v.Required || v.Optional) { + return fmt.Errorf("%s: WriteOnly must be set with either Required or Optional", k) + } + + if v.WriteOnly && v.Computed { + return fmt.Errorf("%s: WriteOnly cannot be set with Computed", k) + } + computedOnly := v.Computed && !v.Optional switch v.ConfigMode { @@ -923,6 +942,10 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro } if v.Type == TypeList || v.Type == TypeSet { + if v.WriteOnly { + return fmt.Errorf("%s: WriteOnly is not valid for lists or sets", k) + } + if v.Elem == nil { return fmt.Errorf("%s: Elem must be set for lists", k) } @@ -956,6 +979,10 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro } if v.Type == TypeMap && v.Elem != nil { + if v.WriteOnly { + return fmt.Errorf("%s: WriteOnly is not valid for maps", k) + } + switch v.Elem.(type) { case *Resource: return fmt.Errorf("%s: TypeMap with Elem *Resource not supported,"+ @@ -2353,6 +2380,30 @@ func (m schemaMap) validateType( return diags } +// hasWriteOnly returns true if the schemaMap contains any +// WriteOnly attributes are set. +func (m schemaMap) hasWriteOnly() bool { + for _, v := range m { + if v.WriteOnly { + return true + } + + if v.Elem != nil { + switch t := v.Elem.(type) { + case *Resource: + return schemaMap(t.SchemaMap()).hasWriteOnly() + case *Schema: + if t.WriteOnly { + return true + } + return schemaMap(map[string]*Schema{"nested": t}).hasWriteOnly() + } + } + } + + return false +} + // Zero returns the zero value for a type. func (t ValueType) Zero() interface{} { switch t { diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index 1c9b1f21988..6099da29d13 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -17,6 +17,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/hcl2shim" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/diagutils" @@ -3125,7 +3126,7 @@ func TestSchemaMap_InternalValidate(t *testing.T) { In map[string]*Schema Err bool }{ - "nothing": { + "nothing returns no error": { nil, false, }, @@ -5051,6 +5052,316 @@ func TestSchemaMap_InternalValidate(t *testing.T) { }, true, }, + + "Attribute with WriteOnly and Required set returns no errors": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Required: true, + WriteOnly: true, + }, + }, + false, + }, + + "Attribute with WriteOnly and Optional set returns no errors": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + false, + }, + + "Attribute with WriteOnly and Computed set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with WriteOnly, Optional, and Computed set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Computed: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with WriteOnly, Optional, Required, and Computed set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Required: true, + Computed: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with WriteOnly, Optional, and Required set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Required: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with only WriteOnly set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + WriteOnly: true, + }, + }, + true, + }, + + "List attribute with WriteOnly set returns error": { + map[string]*Schema{ + "list_attr": { + Type: TypeList, + Required: true, + WriteOnly: true, + Elem: &Schema{Type: TypeString}, + }, + }, + true, + }, + "Map attribute with WriteOnly set returns error": { + map[string]*Schema{ + "map_attr": { + Type: TypeMap, + Required: true, + WriteOnly: true, + Elem: &Schema{Type: TypeString}, + }, + }, + true, + }, + "Set attribute with WriteOnly set returns error": { + map[string]*Schema{ + "set_attr": { + Type: TypeSet, + Required: true, + WriteOnly: true, + Elem: &Schema{Type: TypeString}, + }, + }, + true, + }, + + "List configuration block with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeList, + Optional: true, + WriteOnly: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + }, + }, + }, + }, + }, + true, + }, + "List configuration block nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeList, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + false, + }, + + "Map configuration attribute with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeMap, + Optional: true, + WriteOnly: true, + Elem: &Schema{ + Type: TypeString, + Optional: true, + }, + }, + }, + true, + }, + "Map configuration attribute nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeMap, + Optional: true, + Elem: &Schema{ + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + false, + }, + + "Set configuration block with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Optional: true, + WriteOnly: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + }, + }, + }, + }, + }, + true, + }, + "Set configuration block nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + false, + }, + "List configuration block with ConfigModeAttr set, sub block nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "block": { + Type: TypeList, + ConfigMode: SchemaConfigModeAttr, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "sub": { + Type: TypeList, + ConfigMode: SchemaConfigModeAttr, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + false, + }, + + "Set configuration block with ConfigModeAttr set, sub block nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "block": { + Type: TypeSet, + ConfigMode: SchemaConfigModeAttr, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "sub": { + Type: TypeSet, + ConfigMode: SchemaConfigModeAttr, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + false, + }, + "List computed-only block nested attribute with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeList, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + true, + }, + "Set computed-only block nested attribute with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + true, + }, } for tn, tc := range cases { @@ -8822,3 +9133,275 @@ func TestValidateRequiredWithAttributes(t *testing.T) { }) } } + +func TestHasWriteOnly(t *testing.T) { + cases := map[string]struct { + Schema map[string]*Schema + expectWriteOnly bool + }{ + "Empty returns false": { + Schema: map[string]*Schema{}, + expectWriteOnly: false, + }, + "Top-level WriteOnly set returns true": { + Schema: map[string]*Schema{ + "top-level": { + Type: TypeSet, + Optional: true, + WriteOnly: true, + MinItems: 2, + Elem: &Schema{Type: TypeString}, + }, + }, + expectWriteOnly: true, + }, + "Top-level WriteOnly not set returns false": { + Schema: map[string]*Schema{ + "top-level": { + Type: TypeSet, + Optional: true, + MinItems: 2, + Elem: &Schema{Type: TypeString}, + }, + }, + expectWriteOnly: false, + }, + "Multiple top-level WriteOnly set returns true": { + Schema: map[string]*Schema{ + "top-level1": { + Type: TypeSet, + Optional: true, + MinItems: 2, + Elem: &Schema{Type: TypeString}, + }, + "top-level2": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "top-level3": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + MinItems: 2, + Elem: &Schema{Type: TypeString}, + }, + }, + expectWriteOnly: true, + }, + "Elem set with Resource: no WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + }, + }, + }, + }, + }, + expectWriteOnly: false, + }, + "Elem set with Resource: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + expectWriteOnly: true, + }, + "Double nested Elem set with Resource: no WriteOnly returns false": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_nested_attr": { + Type: TypeString, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + expectWriteOnly: false, + }, + "Double nested Elem set with Resource: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_nested_attr": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + expectWriteOnly: true, + }, + "Elem set with Schema: no WriteOnly returns false": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Computed: true, + }, + }, + }, + expectWriteOnly: false, + }, + "Elem set with Schema: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + expectWriteOnly: true, + }, + "Double nested Elem set with Schema: no WriteOnly returns false": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Elem: &Schema{ + Computed: true, + }, + }, + }, + }, + expectWriteOnly: false, + }, + "Double nested Elem set with Schema: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Elem: &Schema{ + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + expectWriteOnly: true, + }, + "Multiple nested elements: no WriteOnly returns false": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Elem: &Schema{ + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_nested_nested_attr": { + Type: TypeString, + Computed: true, + }, + "nested_nested_nested_attr2": { + Type: TypeString, + Computed: true, + Elem: &Schema{ + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + expectWriteOnly: false, + }, + "Multiple nested elements: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Elem: &Schema{ + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_nested_nested_attr": { + Type: TypeString, + Computed: true, + }, + "nested_nested_nested_attr2": { + Type: TypeString, + Computed: true, + Elem: &Schema{ + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + expectWriteOnly: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + actualWriteOnly := schemaMap(tc.Schema).hasWriteOnly() + if tc.expectWriteOnly != actualWriteOnly { + t.Fatalf("Expected: %t, got: %t", tc.expectWriteOnly, actualWriteOnly) + } + }) + } +} diff --git a/internal/configs/configschema/schema.go b/internal/configs/configschema/schema.go index c445b4ba55e..a45c5cc241d 100644 --- a/internal/configs/configschema/schema.go +++ b/internal/configs/configschema/schema.go @@ -83,6 +83,17 @@ type Attribute struct { // Deprecated indicates whether the attribute has been marked as deprecated in the // provider and usage should be discouraged. Deprecated bool + + // WriteOnly indicates that the practitioner can choose a value for this + // attribute, but Terraform will not store this attribute in state. + // If WriteOnly is true, either Optional or Required must also be true. + // + // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. + // + // This functionality is only supported in Terraform 1.XX and later. TODO: add Terraform version + // Practitioners that choose a value for this attribute with older + // versions of Terraform will receive an error. + WriteOnly bool } // NestedBlock represents the embedding of one block within another. From 17b4a61a9490f894d14acc2eab50960698764b54 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 14:21:25 -0400 Subject: [PATCH 03/32] Add `WriteOnly` validation for data source, provider, and provider meta schemas. --- helper/schema/provider.go | 18 ++++++ helper/schema/provider_test.go | 111 +++++++++++++++++++++++++++------ helper/schema/resource_test.go | 75 ++++++++++------------ 3 files changed, 145 insertions(+), 59 deletions(-) diff --git a/helper/schema/provider.go b/helper/schema/provider.go index a75ae2fc28b..498d0f55429 100644 --- a/helper/schema/provider.go +++ b/helper/schema/provider.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" @@ -192,11 +193,23 @@ func (p *Provider) InternalValidate() error { } var validationErrors []error + + // Provider schema validation sm := schemaMap(p.Schema) if err := sm.InternalValidate(sm); err != nil { validationErrors = append(validationErrors, err) } + if sm.hasWriteOnly() { + validationErrors = append(validationErrors, fmt.Errorf("provider schema cannot contain WriteOnly attributes")) + } + + // Provider meta schema validation + providerMeta := schemaMap(p.ProviderMetaSchema) + if providerMeta.hasWriteOnly() { + validationErrors = append(validationErrors, fmt.Errorf("provider meta schema cannot contain WriteOnly attributes")) + } + // Provider-specific checks for k := range sm { if isReservedProviderFieldName(k) { @@ -214,6 +227,11 @@ func (p *Provider) InternalValidate() error { if err := r.InternalValidate(nil, false); err != nil { validationErrors = append(validationErrors, fmt.Errorf("data source %s: %s", k, err)) } + + dataSourceSchema := schemaMap(r.SchemaMap()) + if dataSourceSchema.hasWriteOnly() { + validationErrors = append(validationErrors, fmt.Errorf("data source %s cannot contain WriteOnly attributes", k)) + } } return errors.Join(validationErrors...) diff --git a/helper/schema/provider_test.go b/helper/schema/provider_test.go index dcab8acd71b..00de0894272 100644 --- a/helper/schema/provider_test.go +++ b/helper/schema/provider_test.go @@ -2288,11 +2288,11 @@ func TestProviderMeta(t *testing.T) { } func TestProvider_InternalValidate(t *testing.T) { - cases := []struct { + cases := map[string]struct { P *Provider ExpectedErr error }{ - { + "Provider with schema returns no errors": { P: &Provider{ Schema: map[string]*Schema{ "foo": { @@ -2303,7 +2303,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: nil, }, - { // Reserved resource fields should be allowed in provider block + "Reserved resource fields in provider block returns no errors": { P: &Provider{ Schema: map[string]*Schema{ "provisioner": { @@ -2318,7 +2318,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: nil, }, - { // Reserved provider fields should not be allowed + "Reserved provider fields returns an error": { // P: &Provider{ Schema: map[string]*Schema{ "alias": { @@ -2329,7 +2329,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: fmt.Errorf("%s is a reserved field name for a provider", "alias"), }, - { // ConfigureFunc and ConfigureContext cannot both be set + "Provider with ConfigureFunc and ConfigureContext both set returns an error": { P: &Provider{ Schema: map[string]*Schema{ "foo": { @@ -2346,22 +2346,97 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: fmt.Errorf("ConfigureFunc and ConfigureContextFunc must not both be set"), }, + "Provider schema with WriteOnly attribute set returns an error": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + ExpectedErr: fmt.Errorf("provider schema cannot contain WriteOnly attributes"), + }, + "Provider meta schema with WriteOnly attribute set returns an error": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + ProviderMetaSchema: map[string]*Schema{ + "meta-foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + ExpectedErr: fmt.Errorf("provider meta schema cannot contain WriteOnly attributes"), + }, + "Data source schema with WriteOnly attribute set returns an error": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + DataSourcesMap: map[string]*Resource{ + "data-foo": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + ExpectedErr: fmt.Errorf("data source data-foo cannot contain WriteOnly attributes"), + }, + "Resource schema with WriteOnly attribute set returns no errors": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + ResourcesMap: map[string]*Resource{ + "resource-foo": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + ExpectedErr: nil, + }, } - for i, tc := range cases { - err := tc.P.InternalValidate() - if tc.ExpectedErr == nil { - if err != nil { - t.Fatalf("%d: Error returned (expected no error): %s", i, err) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := tc.P.InternalValidate() + if tc.ExpectedErr == nil { + if err != nil { + t.Fatalf("Error returned (expected no error): %s", err) + } } - continue - } - if tc.ExpectedErr != nil && err == nil { - t.Fatalf("%d: Expected error (%s), but no error returned", i, tc.ExpectedErr) - } - if err.Error() != tc.ExpectedErr.Error() { - t.Fatalf("%d: Errors don't match. Expected: %#v Given: %#v", i, tc.ExpectedErr, err) - } + if tc.ExpectedErr != nil && err == nil { + t.Fatalf("Expected error (%s), but no error returned", tc.ExpectedErr) + } + if tc.ExpectedErr != nil && err.Error() != tc.ExpectedErr.Error() { + t.Fatalf("Errors don't match. Expected: %#v Given: %#v", tc.ExpectedErr.Error(), err.Error()) + } + }) } } diff --git a/helper/schema/resource_test.go b/helper/schema/resource_test.go index 5c9fd629ba9..447338e6d52 100644 --- a/helper/schema/resource_test.go +++ b/helper/schema/resource_test.go @@ -635,19 +635,18 @@ func TestResourceApply_isNewResource(t *testing.T) { } func TestResourceInternalValidate(t *testing.T) { - cases := []struct { + cases := map[string]struct { In *Resource Writable bool Err bool }{ - 0: { + "nil": { nil, true, true, }, - // No optional and no required - 1: { + "No optional and no required": { &Resource{ Schema: map[string]*Schema{ "foo": { @@ -661,8 +660,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // Update undefined for non-ForceNew field - 2: { + "Update undefined for non-ForceNew field": { &Resource{ Create: Noop, Schema: map[string]*Schema{ @@ -676,8 +674,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // Update defined for ForceNew field - 3: { + "Update defined for ForceNew field": { &Resource{ Create: Noop, Update: Noop, @@ -693,8 +690,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // non-writable doesn't need Update, Create or Delete - 4: { + "non-writable doesn't need Update, Create or Delete": { &Resource{ Schema: map[string]*Schema{ "goo": { @@ -707,8 +703,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - // non-writable *must not* have Create - 5: { + "non-writable *must not* have Create": { &Resource{ Create: Noop, Schema: map[string]*Schema{ @@ -722,8 +717,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // writable must have Read - 6: { + "writable must have Read": { &Resource{ Create: Noop, Update: Noop, @@ -739,8 +733,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // writable must have Delete - 7: { + "writable must have Delete": { &Resource{ Create: Noop, Read: Noop, @@ -756,7 +749,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - 8: { // Reserved name at root should be disallowed + "Reserved name at root should be disallowed": { &Resource{ Create: Noop, Read: Noop, @@ -773,7 +766,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - 9: { // Reserved name at nested levels should be allowed + "Reserved name at nested levels should be allowed": { &Resource{ Create: Noop, Read: Noop, @@ -798,7 +791,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - 10: { // Provider reserved name should be allowed in resource + "Provider reserved name should be allowed in resource": { &Resource{ Create: Noop, Read: Noop, @@ -815,7 +808,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - 11: { // ID should be allowed in data source + "ID should be allowed in data source": { &Resource{ Read: Noop, Schema: map[string]*Schema{ @@ -829,7 +822,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - 12: { // Deprecated ID should be allowed in resource + "Deprecated ID should be allowed in resource": { &Resource{ Create: Noop, Read: Noop, @@ -848,7 +841,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - 13: { // non-writable must not define CustomizeDiff + "non-writable must not define CustomizeDiff": { &Resource{ Read: Noop, Schema: map[string]*Schema{ @@ -862,7 +855,7 @@ func TestResourceInternalValidate(t *testing.T) { false, true, }, - 14: { // Deprecated resource + "Deprecated resource": { &Resource{ Read: Noop, Schema: map[string]*Schema{ @@ -876,7 +869,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 15: { // Create and CreateContext should not both be set + "Create and CreateContext should not both be set": { &Resource{ Create: Noop, CreateContext: NoopContext, @@ -893,7 +886,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 16: { // Read and ReadContext should not both be set + "Read and ReadContext should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -910,7 +903,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 17: { // Update and UpdateContext should not both be set + "Update and UpdateContext should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -927,7 +920,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 18: { // Delete and DeleteContext should not both be set + "Delete and DeleteContext should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -944,7 +937,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 19: { // Create and CreateWithoutTimeout should not both be set + "Create and CreateWithoutTimeout should not both be set": { &Resource{ Create: Noop, CreateWithoutTimeout: NoopContext, @@ -961,7 +954,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 20: { // Read and ReadWithoutTimeout should not both be set + "Read and ReadWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -978,7 +971,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 21: { // Update and UpdateWithoutTimeout should not both be set + "Update and UpdateWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -995,7 +988,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 22: { // Delete and DeleteWithoutTimeout should not both be set + "Delete and DeleteWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -1012,7 +1005,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 23: { // CreateContext and CreateWithoutTimeout should not both be set + "CreateContext and CreateWithoutTimeout should not both be set": { &Resource{ CreateContext: NoopContext, CreateWithoutTimeout: NoopContext, @@ -1029,7 +1022,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 24: { // ReadContext and ReadWithoutTimeout should not both be set + "ReadContext and ReadWithoutTimeout should not both be set": { &Resource{ Create: Noop, ReadContext: NoopContext, @@ -1046,7 +1039,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 25: { // UpdateContext and UpdateWithoutTimeout should not both be set + "UpdateContext and UpdateWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -1063,7 +1056,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 26: { // DeleteContext and DeleteWithoutTimeout should not both be set + "DeleteContext and DeleteWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -1080,7 +1073,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 27: { // Non-Writable SchemaFunc and Schema should not both be set + "Non-Writable SchemaFunc and Schema should not both be set": { In: &Resource{ Schema: map[string]*Schema{ "test": { @@ -1101,7 +1094,7 @@ func TestResourceInternalValidate(t *testing.T) { Writable: false, Err: true, }, - 28: { // Writable SchemaFunc and Schema should not both be set + "Writable SchemaFunc and Schema should not both be set": { In: &Resource{ Schema: map[string]*Schema{ "test": { @@ -1127,18 +1120,18 @@ func TestResourceInternalValidate(t *testing.T) { }, } - for i, tc := range cases { - t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) { + for name, tc := range cases { + t.Run(name, func(t *testing.T) { sm := schemaMap{} if tc.In != nil { sm = schemaMap(tc.In.Schema) } err := tc.In.InternalValidate(sm, tc.Writable) if err != nil && !tc.Err { - t.Fatalf("%d: expected validation to pass: %s", i, err) + t.Fatalf("%s: expected validation to pass: %s", name, err) } if err == nil && tc.Err { - t.Fatalf("%d: expected validation to fail", i) + t.Fatalf("%s: expected validation to fail", name) } }) } From 52183216c83a04c56d3c9473a0de1d1b7ce81c08 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 15:05:26 -0400 Subject: [PATCH 04/32] Add WriteOnly capabilities validation to `ValidateResourceTypeConfig` RPC --- helper/schema/grpc_provider.go | 135 ++++++ helper/schema/grpc_provider_test.go | 618 +++++++++++++++++++++++++++- 2 files changed, 752 insertions(+), 1 deletion(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index ec5d74301a7..4f7f583a6aa 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/hcl2shim" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" @@ -281,6 +282,9 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) return resp, nil } + if req.ClientCapabilities == nil || !req.ClientCapabilities.WriteOnlyAttributesAllowed { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock)) + } config := terraform.NewResourceConfigShimmed(configVal, schemaBlock) @@ -1482,6 +1486,78 @@ func (s *GRPCProviderServer) GetFunctions(ctx context.Context, req *tfprotov5.Ge return resp, nil } +func (s *GRPCProviderServer) ValidateEphemeralResourceConfig(ctx context.Context, req *tfprotov5.ValidateEphemeralResourceConfigRequest) (*tfprotov5.ValidateEphemeralResourceConfigResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Returning error for provider validate ephemeral resource call") + + resp := &tfprotov5.ValidateEphemeralResourceConfigResponse{} + + resp.Diagnostics = []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Ephemeral Resource Not Found ", + Detail: fmt.Sprintf("No ephemeral resource type named %q was found in the provider", req.TypeName), + }, + } + + return resp, nil +} + +func (s *GRPCProviderServer) OpenEphemeralResource(ctx context.Context, req *tfprotov5.OpenEphemeralResourceRequest) (*tfprotov5.OpenEphemeralResourceResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Returning error for provider open ephemeral resource call") + + resp := &tfprotov5.OpenEphemeralResourceResponse{} + + resp.Diagnostics = []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Ephemeral Resource Not Found ", + Detail: fmt.Sprintf("No ephemeral resource type named %q was found in the provider", req.TypeName), + }, + } + + return resp, nil +} + +func (s *GRPCProviderServer) RenewEphemeralResource(ctx context.Context, req *tfprotov5.RenewEphemeralResourceRequest) (*tfprotov5.RenewEphemeralResourceResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Returning error for provider renew ephemeral resource call") + + resp := &tfprotov5.RenewEphemeralResourceResponse{} + + resp.Diagnostics = []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Ephemeral Resource Not Found ", + Detail: fmt.Sprintf("No ephemeral resource type named %q was found in the provider", req.TypeName), + }, + } + + return resp, nil +} + +func (s *GRPCProviderServer) CloseEphemeralResource(ctx context.Context, req *tfprotov5.CloseEphemeralResourceRequest) (*tfprotov5.CloseEphemeralResourceResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Returning error for provider close ephemeral resource call") + + resp := &tfprotov5.CloseEphemeralResourceResponse{} + + resp.Diagnostics = []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Ephemeral Resource Not Found ", + Detail: fmt.Sprintf("No ephemeral resource type named %q was found in the provider", req.TypeName), + }, + } + + return resp, nil +} + func pathToAttributePath(path cty.Path) *tftypes.AttributePath { var steps []tftypes.AttributePathStep @@ -1828,3 +1904,62 @@ func configureDeferralAllowed(in *tfprotov5.ConfigureProviderClientCapabilities) return in.DeferralAllowed } + +// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for each non-null writeOnly attribute value. +func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { + if !val.IsKnown() || val.IsNull() { + return diag.Diagnostics{} + } + + valMap := val.AsValueMap() + diags := make([]diag.Diagnostic, 0) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && !v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), + }) + } + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + continue + } + + blockValType := blockVal.Type() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + + for _, v := range listVals { + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + } + + default: + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + } + } + + return diags +} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 7dacdd6ea5c..f9af8c9a8dd 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -17,10 +17,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/go-cty/cty/msgpack" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/plugin/convert" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -3485,6 +3486,255 @@ func TestGRPCProviderServerMoveResourceState(t *testing.T) { } } +func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + server *GRPCProviderServer + request *tfprotov5.ValidateResourceTypeConfigRequest + expected *tfprotov5.ValidateResourceTypeConfigResponse + }{ + "Provider with empty resource returns no errors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": {}, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{}, + }, + "Server without WriteOnlyAttributesAllowed capabilities: null WriteOnly attribute returns no errors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.Number), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{}, + }, + "Server without WriteOnlyAttributesAllowed capabilities: WriteOnly Attribute with Value returns an error": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + }, + }, + }, + "Server without WriteOnlyAttributesAllowed capabilities: multiple WriteOnly Attributes with Value returns multiple errors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + }, + "Server without WriteOnlyAttributesAllowed capabilities: multiple nested WriteOnly Attributes with Value returns multiple errors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + }, + "config_block_attr": { + Type: TypeList, + Optional: true, + WriteOnly: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + }, + "writeonly_nested_attr": { + Type: TypeString, + WriteOnly: true, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + "config_block_attr": cty.List(cty.Object(map[string]cty.Type{ + "nested_attr": cty.String, + "writeonly_nested_attr": cty.String, + })), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + "config_block_attr": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + "writeonly_nested_attr": cty.StringVal("value"), + }), + }), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\"", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp, err := testCase.server.ValidateResourceTypeConfig(context.Background(), testCase.request) + + if testCase.request != nil && err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if diff := cmp.Diff(resp, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestUpgradeState_jsonState(t *testing.T) { r := &Resource{ SchemaVersion: 2, @@ -6950,6 +7200,372 @@ func Test_pathToAttributePath_noSteps(t *testing.T) { } } +func Test_validateWriteOnlyValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected diag.Diagnostics + }{ + "Empty returns no diags": { + &configschema.Block{}, + cty.EmptyObjectVal, + diag.Diagnostics{}, + }, + "All null values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + diag.Diagnostics{}, + }, + "Set nested block WriteOnly attribute with value returns diag": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("foo_val"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", + }, + }, + }, + "Nested single block, WriteOnly attribute with value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("beep"), + "baz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.StringVal("boop2"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with dynamic value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NumberIntVal(8), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + } { + t.Run(n, func(t *testing.T) { + got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) + + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func mustMsgpackMarshal(ty cty.Type, val cty.Value) []byte { result, err := msgpack.Marshal(val, ty) From bb2bb085c39a6345178b3e3d2ed3bd4375ab6658 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 15:17:10 -0400 Subject: [PATCH 05/32] Skip value validation for `Required` + `WriteOnly` attributes. --- helper/schema/schema.go | 2 +- helper/schema/schema_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index cfb85020b2c..9cffc321bbc 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -1725,7 +1725,7 @@ func (m schemaMap) validate( } if !ok { - if schema.Required { + if schema.Required && !schema.WriteOnly { return append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: "Missing required argument", diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index 6099da29d13..ebe2bcc0801 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -7076,6 +7076,41 @@ func TestSchemaMap_Validate(t *testing.T) { }, }, }, + "Required + WriteOnly attribute with null value returns no errors": { + Schema: map[string]*Schema{ + "write_only_attribute": { + Type: TypeString, + Required: true, + WriteOnly: true, + }, + }, + + Config: nil, + }, + "Required + WriteOnly attribute with default func returns no errors": { + Schema: map[string]*Schema{ + "write_only_attribute": { + Type: TypeString, + Required: true, + WriteOnly: true, + DefaultFunc: func() (interface{}, error) { return "default", nil }, + }, + }, + + Config: nil, + }, + "Required + WriteOnly attribute with default func nil value returns no errors": { + Schema: map[string]*Schema{ + "write_only_attribute": { + Type: TypeString, + Required: true, + WriteOnly: true, + DefaultFunc: func() (interface{}, error) { return nil, nil }, + }, + }, + + Config: nil, + }, } for tn, tc := range cases { From 1d7083181b7a37a471f61e9ba5a8a62e13739082 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 15:38:35 -0400 Subject: [PATCH 06/32] Fix intermittent test failures for `hasWriteOnly()` --- go.sum | 16 ++++------------ helper/schema/schema.go | 6 +++++- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/go.sum b/go.sum index 07f42a50a96..a7a020da742 100644 --- a/go.sum +++ b/go.sum @@ -55,8 +55,7 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= -github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI= github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= @@ -75,10 +74,6 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= -github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= -github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= -github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240827184608-0af265897829 h1:VX0f1Nh8XdurAWeN6ea7AzrVCO0mZQDPTEQdDKbDyTM= -github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240827184608-0af265897829/go.mod h1:ko0HcPe7AkwMukddKa13Qq5zyvi8V9KYxBZmqU8a9cE= github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def h1:/RKsl9EoVaSGf4PgyuDEmnPd2f/x2jdntwx+q0kY2xA= github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def/go.mod h1:ko0HcPe7AkwMukddKa13Qq5zyvi8V9KYxBZmqU8a9cE= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= @@ -199,16 +194,13 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= -google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= -google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= -google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 9cffc321bbc..8dde0469029 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -2396,7 +2396,11 @@ func (m schemaMap) hasWriteOnly() bool { if t.WriteOnly { return true } - return schemaMap(map[string]*Schema{"nested": t}).hasWriteOnly() + + isNestedWriteOnly := schemaMap(map[string]*Schema{"nested": t}).hasWriteOnly() + if isNestedWriteOnly { + return true + } } } } From e7d79dbcf8f0fbf53be4d665a71f6bce01b46245 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 3 Sep 2024 17:44:20 -0400 Subject: [PATCH 07/32] Validate non-null values for `Required` and `WriteOnly` attributes in `PlanResourceChange()` --- helper/schema/grpc_provider.go | 69 +++++ helper/schema/grpc_provider_test.go | 441 +++++++++++++++++++++++++++- 2 files changed, 509 insertions(+), 1 deletion(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 4f7f583a6aa..2f82c6c75b5 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -822,6 +822,16 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot return resp, nil } + // If the resource is being created, validate that all required write-only + // attributes in the config have non-nil values. + if create { + diags := validateWriteOnlyRequiredValues(req.TypeName, configVal, schemaBlock) + if diags.HasError() { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, diags) + return resp, nil + } + } + priorState, err := res.ShimInstanceStateFromValue(priorStateVal) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) @@ -1963,3 +1973,62 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs return diags } + +// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for every WriteOnly + Required attribute null value. +func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { + if !val.IsKnown() || val.IsNull() { + return diag.Diagnostics{} + } + + valMap := val.AsValueMap() + diags := make([]diag.Diagnostic, 0) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && attr.Required && v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + }) + } + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + continue + } + + blockValType := blockVal.Type() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + + for _, v := range listVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + } + + default: + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + } + } + + return diags +} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index f9af8c9a8dd..3ee32217e49 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -4789,6 +4789,74 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, + "create-writeonly-required-null-values": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + ClientCapabilities: &tfprotov5.PlanResourceChangeClientCapabilities{ + DeferralAllowed: true, + }, + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.String), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test\" resource contains a null value for Required WriteOnly attribute \"foo\"", + }, + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, } for name, testCase := range testCases { @@ -7200,7 +7268,7 @@ func Test_pathToAttributePath_noSteps(t *testing.T) { } } -func Test_validateWriteOnlyValues(t *testing.T) { +func Test_validateWriteOnlyNullValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block Val cty.Value @@ -7566,6 +7634,377 @@ func Test_validateWriteOnlyValues(t *testing.T) { } } +func Test_validateWriteOnlyRequiredValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected diag.Diagnostics + }{ + "Empty returns no diags": { + &configschema.Block{}, + cty.EmptyObjectVal, + diag.Diagnostics{}, + }, + "All Required + WriteOnly with values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.StringVal("blep"), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + "biz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{}, + }, + "All Optional + WriteOnly with null values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + diag.Diagnostics{}, + }, + "Set nested block Required + WriteOnly attribute with null return diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"foo\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"boz\"", + }, + }, + }, + "Nested single block, Required + WriteOnly attribute with null returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, Required + WriteOnly attribute with null value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + }, + }, + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + } { + t.Run(n, func(t *testing.T) { + got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) + + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func mustMsgpackMarshal(ty cty.Type, val cty.Value) []byte { result, err := msgpack.Marshal(val, ty) From d171fb75a5aa0fdb946f56e9509a07eab2a1024b Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 5 Sep 2024 15:53:20 -0400 Subject: [PATCH 08/32] Add initial implementation for `PreferWriteOnlyAttribute()` validator --- helper/schema/schema.go | 25 ++ helper/validation/write_only.go | 143 ++++++++++++ helper/validation/write_only_test.go | 326 +++++++++++++++++++++++++++ 3 files changed, 494 insertions(+) create mode 100644 helper/validation/write_only.go create mode 100644 helper/validation/write_only_test.go diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 8dde0469029..fe29e42e6c3 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -371,6 +371,13 @@ type Schema struct { // AttributePath: append(path, cty.IndexStep{Key: cty.StringVal("key_name")}) ValidateDiagFunc SchemaValidateDiagFunc + // ValidateResourceConfig allows a function to define arbitrary validation + // logic during the ValidateResourceTypeConfig RPC. ValidateResourceConfigFunc receives + // the client capabilities from the ValidateResourceTypeConfig RPC and the raw cty + // config value for the entire resource before it is shimmed, and it can return error + // diagnostics based on the inspection of those values. + ValidateResourceConfig ValidateResourceConfigFunc + // Sensitive ensures that the attribute's value does not get displayed in // the Terraform user interface output. It should be used for password or // other values which should be hidden. @@ -476,6 +483,24 @@ type SchemaValidateFunc func(interface{}, string) ([]string, []error) // schema and has Diagnostic support. type SchemaValidateDiagFunc func(interface{}, cty.Path) diag.Diagnostics +// ValidateResourceConfigFunc is a function used to validate the raw resource config +// and has Diagnostic support. +type ValidateResourceConfigFunc func(context.Context, ValidateResourceConfigRequest, *ValidateResourceConfigResponse) + +type ValidateResourceConfigRequest struct { + // WriteOnlyAttributesAllowed indicates that the Terraform client + // initiating the request supports write-only attributes for managed + // resources. + WriteOnlyAttributesAllowed bool + + // The raw config value provided by Terraform core + RawConfig cty.Value +} + +type ValidateResourceConfigResponse struct { + Diagnostics diag.Diagnostics +} + func (s *Schema) GoString() string { return fmt.Sprintf("*%#v", *s) } diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go new file mode 100644 index 00000000000..b729c81a2f3 --- /dev/null +++ b/helper/validation/write_only.go @@ -0,0 +1,143 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validation + +import ( + "context" + "fmt" + + "github.com/hashicorp/go-cty/cty" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// PreferWriteOnlyAttribute is a ValidateResourceConfigFunc that returns a warning +// if the Terraform client supports write-only attributes and the old attribute +// has a value instead of the write-only attribute. +func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttribute cty.Path) schema.ValidateResourceConfigFunc { + return func(ctx context.Context, req schema.ValidateResourceConfigRequest, resp *schema.ValidateResourceConfigResponse) { + if !req.WriteOnlyAttributesAllowed { + return + } + + // Apply all but the last step to retrieve the attribute name + // for any diags that we return. + oldLastStepVal, oldLastStep, err := oldAttribute.LastStep(req.RawConfig) + if err != nil { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: fmt.Sprintf("Encountered an error when applying the specified oldAttribute path, "+ + "original error: %s", err), + AttributePath: oldAttribute, + }, + } + return + } + + // Only attribute steps have a Name field + oldAttributeStep, ok := oldLastStep.(cty.GetAttrStep) + if !ok { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "The specified oldAttribute path must point to an attribute", + AttributePath: oldAttribute, + }, + } + return + } + + oldAttributeConfigVal, err := oldAttributeStep.Apply(oldLastStepVal) + if err != nil { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: fmt.Sprintf("Encountered an error when applying the specified oldAttribute path, "+ + "original error: %s", err), + AttributePath: oldAttribute, + }, + } + return + } + + writeOnlyLastStepVal, writeOnlyLastStep, err := writeOnlyAttribute.LastStep(req.RawConfig) + if err != nil { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: fmt.Sprintf("Encountered an error when applying the specified writeOnlyAttribute path, "+ + "original error: %s", err), + AttributePath: writeOnlyAttribute, + }, + } + return + } + + // Only attribute steps have a Name field + writeOnlyAttributeStep, ok := writeOnlyLastStep.(cty.GetAttrStep) + if !ok { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: "The specified writeOnlyAttribute path must point to an attribute", + AttributePath: writeOnlyAttribute, + }, + } + return + } + + writeOnlyAttributeConfigVal, err := writeOnlyAttributeStep.Apply(writeOnlyLastStepVal) + if err != nil { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: fmt.Sprintf("Encountered an error when applying the specified writeOnlyAttribute path, "+ + "original error: %s", err), + AttributePath: writeOnlyAttribute, + }, + } + return + } + + //oldAttributeConfigVal, err := cty.Transform(req.RawConfig, func(path cty.Path, val cty.Value) (cty.Value, error) { + // if path.Equals(oldAttribute) { + // oldAttributeConfig := req.RawConfig.GetAttr(oldAttributeName) + // println(oldAttributeConfig.IsKnown()) + // return val, nil + // } + // + // // nothing to do if we already have a value + // if !val.IsNull() { + // return val, nil + // } + // + // return val, nil + //}) + //// We shouldn't encounter any errors here, but handling them just in case. + //if err != nil { + // resp.Diagnostics = diag.FromErr(err) + // return + //} + + if !oldAttributeConfigVal.IsNull() && writeOnlyAttributeConfigVal.IsNull() { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: fmt.Sprintf("The attribute %s has a WriteOnly version %s available. "+ + "Use the WriteOnly version of the attribute when possible.", oldAttributeStep.Name, writeOnlyAttributeStep.Name), + AttributePath: oldAttribute, + }, + } + } + } +} diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go new file mode 100644 index 00000000000..69e6edb8985 --- /dev/null +++ b/helper/validation/write_only_test.go @@ -0,0 +1,326 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validation + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-cty/cty" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestPreferWriteOnlyAttribute(t *testing.T) { + cases := map[string]struct { + oldAttributePath cty.Path + writeOnlyAttributePath cty.Path + validateConfigReq schema.ValidateResourceConfigRequest + expectedDiags diag.Diagnostics + }{ + "writeOnlyAttributeAllowed unset returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: false, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NullVal(cty.Number), + }), + }, + }, + "oldAttribute and writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + }, + "writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.Number), + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + }, + "oldAttributePath pointing to missing attribute returns error diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "Encountered an error when applying the specified oldAttribute path, original error: object has no attribute \"oldAttribute\"", + AttributePath: cty.Path{cty.GetAttrStep{Name: "oldAttribute"}}, + }, + }, + }, + "writeOnlyAttributePath pointing to missing attribute returns error diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: "Encountered an error when applying the specified writeOnlyAttribute path, original error: object has no attribute \"writeOnlyAttribute\"", + AttributePath: cty.Path{cty.GetAttrStep{Name: "writeOnlyAttribute"}}, + }, + }, + }, + "oldAttributePath with empty path returns error diag": { + oldAttributePath: cty.Path{}, + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "The specified oldAttribute path must point to an attribute", + AttributePath: cty.Path{}, + }, + }, + }, + "writeOnlyAttributePath with empty path returns error diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.Path{}, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: "The specified writeOnlyAttribute path must point to an attribute", + AttributePath: cty.Path{}, + }, + }, + }, + "only oldAttribute set returns warning diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NullVal(cty.Number), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{cty.GetAttrStep{Name: "oldAttribute"}}, + }, + }, + }, + "block: oldAttribute and writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }, + }, + "block: writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }, + }, + "block: only oldAttribute set returns warning diag": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "set nested block: oldAttribute and writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), + })}, + cty.IndexStep{Key: cty.StringVal("oldAttribute")}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.IndexStep{Key: cty.StringVal("writeOnlyAttribute")}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }), + }, + }, + "set nested block: writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }, + }, + "set nested block: only oldAttribute set returns warning diag": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := PreferWriteOnlyAttribute(tc.oldAttributePath, tc.writeOnlyAttributePath) + + actual := &schema.ValidateResourceConfigResponse{} + f(context.Background(), tc.validateConfigReq, actual) + + if len(actual.Diagnostics) == 0 && tc.expectedDiags == nil { + return + } + + if len(actual.Diagnostics) != 0 && tc.expectedDiags == nil { + t.Fatalf("expected no diagnostics but got %v", actual.Diagnostics) + } + + if diff := cmp.Diff(tc.expectedDiags, actual.Diagnostics, cmp.AllowUnexported(cty.GetAttrStep{})); diff != "" { + t.Errorf("Unexpected diagnostics (-wanted +got): %s", diff) + } + }) + } +} From 25c2a13a8cfa04aadd4cd61f699495695ea0a497 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 9 Sep 2024 19:02:14 -0400 Subject: [PATCH 09/32] Finish `PreferWriteOnlyAttribute()` validator implementation. --- helper/validation/path.go | 55 +++ helper/validation/path_test.go | 116 +++++ helper/validation/write_only.go | 153 ++---- helper/validation/write_only_test.go | 699 ++++++++++++++++++++++----- 4 files changed, 798 insertions(+), 225 deletions(-) create mode 100644 helper/validation/path.go create mode 100644 helper/validation/path_test.go diff --git a/helper/validation/path.go b/helper/validation/path.go new file mode 100644 index 00000000000..b17449a3d5a --- /dev/null +++ b/helper/validation/path.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validation + +import ( + "github.com/hashicorp/go-cty/cty" +) + +// PathEquals compares two Paths for equality. For cty.IndexStep, +// unknown key values are treated as an Any qualifier and will +// match any index step of the same type. +func PathEquals(p cty.Path, other cty.Path) bool { + if len(p) != len(other) { + return false + } + + for i := range p { + pv := p[i] + switch pv := pv.(type) { + case cty.GetAttrStep: + ov, ok := other[i].(cty.GetAttrStep) + if !ok || pv != ov { + return false + } + case cty.IndexStep: + ov, ok := other[i].(cty.IndexStep) + if !ok { + return false + } + + // Sets need special handling since their Type is the entire object + // with attributes. + if pv.Key.Type().IsObjectType() && ov.Key.Type().IsObjectType() { + if !pv.Key.IsKnown() || !ov.Key.IsKnown() { + break + } + } + if !pv.Key.Type().Equals(ov.Key.Type()) { + return false + } + + if pv.Key.IsKnown() && ov.Key.IsKnown() { + if !pv.Key.RawEquals(ov.Key) { + return false + } + } + default: + // Any invalid steps default to evaluating false. + return false + } + } + + return true +} diff --git a/helper/validation/path_test.go b/helper/validation/path_test.go new file mode 100644 index 00000000000..aabb2dc281a --- /dev/null +++ b/helper/validation/path_test.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validation + +import ( + "testing" + + "github.com/hashicorp/go-cty/cty" +) + +func TestPathEquals(t *testing.T) { + tests := map[string]struct { + p cty.Path + other cty.Path + want bool + }{ + "null paths returns true": { + p: nil, + other: nil, + want: true, + }, + "empty paths returns true": { + p: cty.Path{}, + other: cty.Path{}, + want: true, + }, + "exact same path returns true": { + p: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + want: true, + }, + "path with unknown number index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + want: true, + }, + "other path with unknown number index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + want: true, + }, + "both paths with unknown number index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + want: true, + }, + "path with unknown string index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.StringVal("key")).GetAttr("nestedAttribute"), + want: true, + }, + "other path with unknown string index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.StringVal("key")).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + want: true, + }, + "both paths with unknown string index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + want: true, + }, + "path with unknown object index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Object(nil))).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.ObjectVal( + map[string]cty.Value{ + "oldAttribute": cty.StringVal("old"), + "writeOnlyAttribute": cty.StringVal("writeOnly"), + }, + )).GetAttr("nestedAttribute"), + want: true, + }, + "other path with unknown object index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.ObjectVal( + map[string]cty.Value{ + "oldAttribute": cty.StringVal("old"), + "writeOnlyAttribute": cty.StringVal("writeOnly"), + }, + )).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Object(nil))).GetAttr("nestedAttribute"), + want: true, + }, + "both paths with unknown object index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Object(nil))).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Object(nil))).GetAttr("nestedAttribute"), + want: true, + }, + "paths with unequal steps return false": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)), + other: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + want: false, + }, + "paths with mismatched attribute names return false": { + p: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("incorrect").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + want: false, + }, + "paths with mismatched unknown index types return false": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + want: false, + }, + "other path with unknown index, different type return false": { + p: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + want: false, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if got := PathEquals(tc.p, tc.other); got != tc.want { + t.Errorf("PathEquals() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go index b729c81a2f3..1fa9636814f 100644 --- a/helper/validation/write_only.go +++ b/helper/validation/write_only.go @@ -14,130 +14,75 @@ import ( ) // PreferWriteOnlyAttribute is a ValidateResourceConfigFunc that returns a warning -// if the Terraform client supports write-only attributes and the old attribute -// has a value instead of the write-only attribute. -func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttribute cty.Path) schema.ValidateResourceConfigFunc { - return func(ctx context.Context, req schema.ValidateResourceConfigRequest, resp *schema.ValidateResourceConfigResponse) { +// if the Terraform client supports write-only attributes and the old attribute is +// not null. +// The last step in the path must be a cty.GetAttrStep{}. +// When creating a cty.IndexStep{} to into a nested attribute, use an unknown value +// of the index type to indicate any key value. +// For lists: cty.Index(cty.UnknownVal(cty.Number)), +// For maps: cty.Index(cty.UnknownVal(cty.String)), +// For sets: cty.Index(cty.UnknownVal(cty.Object(nil))), +func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName string) schema.ValidateResourceConfigFunc { + return func(ctx context.Context, req schema.ValidateResourceConfigFuncRequest, resp *schema.ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { return } - // Apply all but the last step to retrieve the attribute name - // for any diags that we return. - oldLastStepVal, oldLastStep, err := oldAttribute.LastStep(req.RawConfig) - if err != nil { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: fmt.Sprintf("Encountered an error when applying the specified oldAttribute path, "+ - "original error: %s", err), - AttributePath: oldAttribute, - }, - } - return - } + var oldAttrs []attribute - // Only attribute steps have a Name field - oldAttributeStep, ok := oldLastStep.(cty.GetAttrStep) - if !ok { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "The specified oldAttribute path must point to an attribute", - AttributePath: oldAttribute, - }, + err := cty.Walk(req.RawConfig, func(path cty.Path, value cty.Value) (bool, error) { + if PathEquals(path, oldAttribute) { + oldAttrs = append(oldAttrs, attribute{ + value: value, + path: path, + }) } - return - } - oldAttributeConfigVal, err := oldAttributeStep.Apply(oldLastStepVal) + return true, nil + }) if err != nil { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: fmt.Sprintf("Encountered an error when applying the specified oldAttribute path, "+ - "original error: %s", err), - AttributePath: oldAttribute, - }, - } return } - writeOnlyLastStepVal, writeOnlyLastStep, err := writeOnlyAttribute.LastStep(req.RawConfig) - if err != nil { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: fmt.Sprintf("Encountered an error when applying the specified writeOnlyAttribute path, "+ - "original error: %s", err), - AttributePath: writeOnlyAttribute, - }, - } - return - } + for _, attr := range oldAttrs { + attrPath := attr.path.Copy() - // Only attribute steps have a Name field - writeOnlyAttributeStep, ok := writeOnlyLastStep.(cty.GetAttrStep) - if !ok { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: "The specified writeOnlyAttribute path must point to an attribute", - AttributePath: writeOnlyAttribute, - }, - } - return - } + pathLen := len(attrPath) - writeOnlyAttributeConfigVal, err := writeOnlyAttributeStep.Apply(writeOnlyLastStepVal) - if err != nil { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: fmt.Sprintf("Encountered an error when applying the specified writeOnlyAttribute path, "+ - "original error: %s", err), - AttributePath: writeOnlyAttribute, - }, + if pathLen == 0 { + return } - return - } - //oldAttributeConfigVal, err := cty.Transform(req.RawConfig, func(path cty.Path, val cty.Value) (cty.Value, error) { - // if path.Equals(oldAttribute) { - // oldAttributeConfig := req.RawConfig.GetAttr(oldAttributeName) - // println(oldAttributeConfig.IsKnown()) - // return val, nil - // } - // - // // nothing to do if we already have a value - // if !val.IsNull() { - // return val, nil - // } - // - // return val, nil - //}) - //// We shouldn't encounter any errors here, but handling them just in case. - //if err != nil { - // resp.Diagnostics = diag.FromErr(err) - // return - //} + lastStep := attrPath[pathLen-1] + + // Only attribute steps have a Name field + attrStep, ok := lastStep.(cty.GetAttrStep) + if !ok { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "The specified oldAttribute path must point to an attribute.", + AttributePath: attrPath, + }, + } + return + } - if !oldAttributeConfigVal.IsNull() && writeOnlyAttributeConfigVal.IsNull() { - resp.Diagnostics = diag.Diagnostics{ - { + if !attr.value.IsNull() { + resp.Diagnostics = append(resp.Diagnostics, diag.Diagnostic{ Severity: diag.Warning, Summary: "Available Write-Only Attribute Alternative", Detail: fmt.Sprintf("The attribute %s has a WriteOnly version %s available. "+ - "Use the WriteOnly version of the attribute when possible.", oldAttributeStep.Name, writeOnlyAttributeStep.Name), - AttributePath: oldAttribute, - }, + "Use the WriteOnly version of the attribute when possible.", attrStep.Name, writeOnlyAttributeName), + AttributePath: attr.path, + }) } } } } + +type attribute struct { + value cty.Value + path cty.Path +} diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go index 69e6edb8985..d641c490ea0 100644 --- a/helper/validation/write_only_test.go +++ b/helper/validation/write_only_test.go @@ -16,15 +16,13 @@ import ( func TestPreferWriteOnlyAttribute(t *testing.T) { cases := map[string]struct { - oldAttributePath cty.Path - writeOnlyAttributePath cty.Path - validateConfigReq schema.ValidateResourceConfigRequest - expectedDiags diag.Diagnostics + oldAttributePath cty.Path + validateConfigReq schema.ValidateResourceConfigFuncRequest + expectedDiags diag.Diagnostics }{ - "writeOnlyAttributeAllowed unset returns no diags": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "writeOnlyAttributeAllowed set to false with oldAttribute set returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: false, RawConfig: cty.ObjectVal(map[string]cty.Value{ "oldAttribute": cty.NumberIntVal(42), @@ -32,106 +30,85 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }), }, }, - "oldAttribute and writeOnlyAttribute set returns no diags": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "invalid oldAttributePath returns error diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute").Index(cty.UnknownVal(cty.String)), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NumberIntVal(42), - "writeOnlyAttribute": cty.NumberIntVal(42), + "oldAttribute": cty.ListVal([]cty.Value{ + cty.StringVal("val1"), + cty.StringVal("val2"), + }), + "writeOnlyAttribute": cty.NullVal(cty.Number), }), }, - }, - "writeOnlyAttribute set returns no diags": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ - WriteOnlyAttributesAllowed: true, - RawConfig: cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NullVal(cty.Number), - "writeOnlyAttribute": cty.NumberIntVal(42), - }), + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "The specified oldAttribute path must point to an attribute.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "oldAttribute"}, + cty.IndexStep{ + Key: cty.NumberIntVal(1), + }, + }, + }, }, }, - "oldAttributePath pointing to missing attribute returns error diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "oldAttribute and writeOnlyAttribute set returns warning diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), "writeOnlyAttribute": cty.NumberIntVal(42), }), }, expectedDiags: diag.Diagnostics{ { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "Encountered an error when applying the specified oldAttribute path, original error: object has no attribute \"oldAttribute\"", + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", AttributePath: cty.Path{cty.GetAttrStep{Name: "oldAttribute"}}, }, }, }, - "writeOnlyAttributePath pointing to missing attribute returns error diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NumberIntVal(42), + "oldAttribute": cty.NullVal(cty.Number), + "writeOnlyAttribute": cty.NumberIntVal(42), }), }, - expectedDiags: diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: "Encountered an error when applying the specified writeOnlyAttribute path, original error: object has no attribute \"writeOnlyAttribute\"", - AttributePath: cty.Path{cty.GetAttrStep{Name: "writeOnlyAttribute"}}, - }, - }, }, - "oldAttributePath with empty path returns error diag": { - oldAttributePath: cty.Path{}, - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "oldAttributePath pointing to missing attribute returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NumberIntVal(42), "writeOnlyAttribute": cty.NumberIntVal(42), }), }, - expectedDiags: diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "The specified oldAttribute path must point to an attribute", - AttributePath: cty.Path{}, - }, - }, + expectedDiags: nil, }, - "writeOnlyAttributePath with empty path returns error diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.Path{}, - validateConfigReq: schema.ValidateResourceConfigRequest{ + "oldAttributePath with empty path returns no diags": { + oldAttributePath: cty.Path{}, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "oldAttribute": cty.NumberIntVal(42), "writeOnlyAttribute": cty.NumberIntVal(42), }), }, - expectedDiags: diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: "The specified writeOnlyAttribute path must point to an attribute", - AttributePath: cty.Path{}, - }, - }, + expectedDiags: nil, }, "only oldAttribute set returns warning diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "oldAttribute": cty.NumberIntVal(42), @@ -148,16 +125,12 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, }, }, - "block: oldAttribute and writeOnlyAttribute set returns no diags": { + "block: oldAttribute and writeOnlyAttribute set returns warning diag": { oldAttributePath: cty.Path{ cty.GetAttrStep{Name: "config_block_attr"}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, - }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -167,17 +140,25 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }), }), }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, }, "block: writeOnlyAttribute set returns no diags": { oldAttributePath: cty.Path{ cty.GetAttrStep{Name: "config_block_attr"}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, - }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -193,11 +174,7 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { cty.GetAttrStep{Name: "config_block_attr"}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, - }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -220,23 +197,151 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, }, }, - "set nested block: oldAttribute and writeOnlyAttribute set returns no diags": { + "list nested block: oldAttribute and writeOnlyAttribute set returns warning diag": { oldAttributePath: cty.Path{ - cty.GetAttrStep{Name: "set_nested_block"}, - cty.IndexStep{Key: cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.StringVal("value"), - "writeOnlyAttribute": cty.StringVal("value"), + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Number)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), }), - })}, - cty.IndexStep{Key: cty.StringVal("oldAttribute")}, + }), }, - writeOnlyAttributePath: cty.Path{ + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "list nested block: writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Number)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: nil, + }, + "list nested block: only oldAttribute set returns warning diag": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Number)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "list nested block: multiple oldAttribute set returns multiple warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Number)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(2)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "set nested block: oldAttribute and writeOnlyAttribute set returns warning diags": { + oldAttributePath: cty.Path{ cty.GetAttrStep{Name: "set_nested_block"}, - cty.IndexStep{Key: cty.NumberIntVal(0)}, - cty.IndexStep{Key: cty.StringVal("writeOnlyAttribute")}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object( + map[string]cty.Type{ + "oldAttribute": cty.String, + "writeOnlyAttribute": cty.String, + }, + ))}, + cty.GetAttrStep{Name: "oldAttribute"}, }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -248,43 +353,106 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }), }), }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, }, "set nested block: writeOnlyAttribute set returns no diags": { oldAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object( + map[string]cty.Type{ + "oldAttribute": cty.String, + }, + ))}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, - }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), - "config_block_attr": cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NullVal(cty.String), - "writeOnlyAttribute": cty.StringVal("value"), + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.StringVal("value"), + }), }), }), }, + expectedDiags: nil, }, "set nested block: only oldAttribute set returns warning diag": { oldAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object( + map[string]cty.Type{ + "oldAttribute": cty.String, + }, + ))}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "set nested block: multiple oldAttribute set returns multiple warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object(nil))}, + cty.GetAttrStep{Name: "oldAttribute"}, }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), - "config_block_attr": cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.StringVal("value"), - "writeOnlyAttribute": cty.NullVal(cty.String), + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), }), }), }, @@ -295,7 +463,286 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + "Use the WriteOnly version of the attribute when possible.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "map nested block: oldAttribute and writeOnlyAttribute map returns warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "map nested block: writeOnlyAttribute map returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: nil, + }, + "map nested block: only oldAttribute map returns warning diag": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "map nested block: multiple oldAttribute map returns multiple warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + "key2": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + "key3": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key3")}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "map nested set nested block: multiple oldAttribute map returns multiple warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object(nil))}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + "string_nested_attribute": cty.NullVal(cty.String), + }), + "key2": cty.ObjectVal(map[string]cty.Value{ + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + }), + "string_nested_attribute": cty.StringVal("value1"), + }), + "key3": cty.ObjectVal(map[string]cty.Value{ + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + "string_nested_attribute": cty.StringVal("value1"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key3")}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key3")}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }, cty.GetAttrStep{Name: "oldAttribute"}, }, }, @@ -305,9 +752,9 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - f := PreferWriteOnlyAttribute(tc.oldAttributePath, tc.writeOnlyAttributePath) + f := PreferWriteOnlyAttribute(tc.oldAttributePath, "writeOnlyAttribute") - actual := &schema.ValidateResourceConfigResponse{} + actual := &schema.ValidateResourceConfigFuncResponse{} f(context.Background(), tc.validateConfigReq, actual) if len(actual.Diagnostics) == 0 && tc.expectedDiags == nil { @@ -318,9 +765,19 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { t.Fatalf("expected no diagnostics but got %v", actual.Diagnostics) } - if diff := cmp.Diff(tc.expectedDiags, actual.Diagnostics, cmp.AllowUnexported(cty.GetAttrStep{})); diff != "" { + if diff := cmp.Diff(tc.expectedDiags, actual.Diagnostics, + cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), + cmp.Comparer(indexStepComparer), + ); diff != "" { t.Errorf("Unexpected diagnostics (-wanted +got): %s", diff) } }) } } + +func indexStepComparer(step cty.IndexStep, other cty.IndexStep) bool { + if !step.Key.RawEquals(other.Key) { + return false + } + return true +} From 2f917f8b0d8e589d585ce46e25dbee923bf49b5f Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 9 Sep 2024 19:03:54 -0400 Subject: [PATCH 10/32] Move `schema.ValidateResourceConfigFuncs` to `schema.Resource` and implement validation in `ValidateResourceTypeConfig()` RPC --- helper/schema/grpc_provider.go | 23 +++ helper/schema/grpc_provider_test.go | 221 +++++++++++++++++++++++++++ helper/schema/resource.go | 29 ++++ helper/schema/schema.go | 25 --- helper/validation/write_only_test.go | 9 +- 5 files changed, 276 insertions(+), 31 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 2f82c6c75b5..34c67bb4379 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -286,6 +286,29 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock)) } + r := s.provider.ResourcesMap[req.TypeName] + + // Calling ValidateResourceConfigFunc here since provider.ValidateResource() + // is a public function, so we can't change its signature. + if r.ValidateResourceConfigFuncs != nil { + writeOnlyAllowed := false + + if req.ClientCapabilities != nil { + writeOnlyAllowed = req.ClientCapabilities.WriteOnlyAttributesAllowed + } + + validateReq := ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: writeOnlyAllowed, + RawConfig: configVal, + } + + for _, validateFunc := range r.ValidateResourceConfigFuncs { + validateResp := &ValidateResourceConfigFuncResponse{} + validateFunc(ctx, validateReq, validateResp) + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateResp.Diagnostics) + } + } + config := terraform.NewResourceConfigShimmed(configVal, schemaBlock) logging.HelperSchemaTrace(ctx, "Calling downstream") diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 3ee32217e49..1c006084379 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3714,6 +3714,227 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { }, }, }, + "Server with ValidateResourceConfigFunc: WriteOnlyAttributesAllowed true returns diags": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + if req.WriteOnlyAttributesAllowed { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + if req.WriteOnlyAttributesAllowed { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + ClientCapabilities: &tfprotov5.ValidateResourceTypeConfigClientCapabilities{ + WriteOnlyAttributesAllowed: true, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + }, + }, + }, + "Server with ValidateResourceConfigFunc: WriteOnlyAttributesAllowed false returns diags": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + if !req.WriteOnlyAttributesAllowed { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + if !req.WriteOnlyAttributesAllowed { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + }, + }, + }, + "Server with ValidateResourceConfigFunc: equal config value returns diags": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + equals := req.RawConfig.Equals(cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + })) + if equals.True() { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + equals := req.RawConfig.Equals(cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + })) + if equals.True() { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + }, + }, + }, } for name, testCase := range testCases { diff --git a/helper/schema/resource.go b/helper/schema/resource.go index 1c944c9b481..dd52d1ca777 100644 --- a/helper/schema/resource.go +++ b/helper/schema/resource.go @@ -644,6 +644,16 @@ type Resource struct { // ResourceBehavior is used to control SDK-specific logic when // interacting with this resource. ResourceBehavior ResourceBehavior + + // ValidateResourceConfigFuncs allows functions to define arbitrary validation + // logic during the ValidateResourceTypeConfig RPC. ValidateResourceConfigFunc receives + // the client capabilities from the ValidateResourceTypeConfig RPC and the raw cty + // config value for the entire resource before it is shimmed, and it can return error + // diagnostics based on the inspection of those values. + // + // ValidateResourceConfigFuncs is only valid for Managed Resource types and will not be + // called for Data Resource or Block types. + ValidateResourceConfigFuncs []ValidateResourceConfigFunc } // ResourceBehavior controls SDK-specific logic when interacting @@ -670,6 +680,25 @@ type ProviderDeferredBehavior struct { EnablePlanModification bool } +// ValidateResourceConfigFunc is a function used to validate the raw resource config +// and has Diagnostic support. it is only valid for Managed Resource types and will not be +// called for Data Resource or Block types. +type ValidateResourceConfigFunc func(context.Context, ValidateResourceConfigFuncRequest, *ValidateResourceConfigFuncResponse) + +type ValidateResourceConfigFuncRequest struct { + // WriteOnlyAttributesAllowed indicates that the Terraform client + // initiating the request supports write-only attributes for managed + // resources. + WriteOnlyAttributesAllowed bool + + // The raw config value provided by Terraform core + RawConfig cty.Value +} + +type ValidateResourceConfigFuncResponse struct { + Diagnostics diag.Diagnostics +} + // SchemaMap returns the schema information for this Resource whether it is // defined via the SchemaFunc field or Schema field. The SchemaFunc field, if // defined, takes precedence over the Schema field. diff --git a/helper/schema/schema.go b/helper/schema/schema.go index fe29e42e6c3..8dde0469029 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -371,13 +371,6 @@ type Schema struct { // AttributePath: append(path, cty.IndexStep{Key: cty.StringVal("key_name")}) ValidateDiagFunc SchemaValidateDiagFunc - // ValidateResourceConfig allows a function to define arbitrary validation - // logic during the ValidateResourceTypeConfig RPC. ValidateResourceConfigFunc receives - // the client capabilities from the ValidateResourceTypeConfig RPC and the raw cty - // config value for the entire resource before it is shimmed, and it can return error - // diagnostics based on the inspection of those values. - ValidateResourceConfig ValidateResourceConfigFunc - // Sensitive ensures that the attribute's value does not get displayed in // the Terraform user interface output. It should be used for password or // other values which should be hidden. @@ -483,24 +476,6 @@ type SchemaValidateFunc func(interface{}, string) ([]string, []error) // schema and has Diagnostic support. type SchemaValidateDiagFunc func(interface{}, cty.Path) diag.Diagnostics -// ValidateResourceConfigFunc is a function used to validate the raw resource config -// and has Diagnostic support. -type ValidateResourceConfigFunc func(context.Context, ValidateResourceConfigRequest, *ValidateResourceConfigResponse) - -type ValidateResourceConfigRequest struct { - // WriteOnlyAttributesAllowed indicates that the Terraform client - // initiating the request supports write-only attributes for managed - // resources. - WriteOnlyAttributesAllowed bool - - // The raw config value provided by Terraform core - RawConfig cty.Value -} - -type ValidateResourceConfigResponse struct { - Diagnostics diag.Diagnostics -} - func (s *Schema) GoString() string { return fmt.Sprintf("*%#v", *s) } diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go index d641c490ea0..8adf6d70f4e 100644 --- a/helper/validation/write_only_test.go +++ b/helper/validation/write_only_test.go @@ -31,7 +31,7 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, }, "invalid oldAttributePath returns error diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute").Index(cty.UnknownVal(cty.String)), + oldAttributePath: cty.GetAttrPath("oldAttribute").Index(cty.UnknownVal(cty.Number)), validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ @@ -50,7 +50,7 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { AttributePath: cty.Path{ cty.GetAttrStep{Name: "oldAttribute"}, cty.IndexStep{ - Key: cty.NumberIntVal(1), + Key: cty.NumberIntVal(0), }, }, }, @@ -776,8 +776,5 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { } func indexStepComparer(step cty.IndexStep, other cty.IndexStep) bool { - if !step.Key.RawEquals(other.Key) { - return false - } - return true + return step.Key.RawEquals(other.Key) } From cab2a27fd32851686a4e8de9f50a73e9f777ba1e Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 10 Sep 2024 13:23:40 -0400 Subject: [PATCH 11/32] Add automatic state handling for writeOnly attributes --- helper/schema/grpc_provider.go | 102 ++++++++ helper/schema/grpc_provider_test.go | 385 ++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 34c67bb4379..8bc0190e64a 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -1219,6 +1219,8 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro newStateVal = copyTimeoutValues(newStateVal, plannedStateVal) + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) @@ -2055,3 +2057,103 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con return diags } + +// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null +// values that are writeOnly to null. +func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { + if !val.IsKnown() || val.IsNull() { + return val + } + + valMap := val.AsValueMap() + newVals := make(map[string]cty.Value) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && !v.IsNull() { + newVals[name] = cty.NullVal(attr.Type) + continue + } + + newVals[name] = v + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + newVals[name] = blockVal + continue + } + + blockValType := blockVal.Type() + blockElementType := blockS.Block.ImpliedType() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) + + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + newListVals := make([]cty.Value, 0, len(listVals)) + + for _, v := range listVals { + newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) + } + + switch { + case blockValType.IsSetType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.SetValEmpty(blockElementType) + default: + newVals[name] = cty.SetVal(newListVals) + } + case blockValType.IsListType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.ListValEmpty(blockElementType) + default: + newVals[name] = cty.ListVal(newListVals) + } + case blockValType.IsTupleType(): + newVals[name] = cty.TupleVal(newListVals) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + newMapVals := make(map[string]cty.Value) + + for k, v := range mapVals { + newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) + } + + switch { + case blockValType.IsMapType(): + switch len(newMapVals) { + case 0: + newVals[name] = cty.MapValEmpty(blockElementType) + default: + newVals[name] = cty.MapVal(newMapVals) + } + case blockValType.IsObjectType(): + if len(newMapVals) == 0 { + // We need to populate empty values to make a valid object. + for attr, ty := range blockElementType.AttributeTypes() { + newMapVals[attr] = cty.NullVal(ty) + } + } + newVals[name] = cty.ObjectVal(newMapVals) + } + + default: + panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) + } + } + + return cty.ObjectVal(newVals) +} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 1c006084379..6e357770b92 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -8226,6 +8226,391 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { } } +func Test_setWriteOnlyNullValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected cty.Value + }{ + "Empty returns no empty object": { + &configschema.Block{}, + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }, + "Top level attributes and block: write only attributes with values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.StringVal("blep"), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + "biz": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.NullVal(cty.String), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + "biz": cty.StringVal("boop"), + }), + }), + }, + "Top level attributes and block: all null values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + }, + "Set nested block: write only Nested Attribute": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("beep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + }), + }), + }), + }, + "Nested single block: write only nested attribute": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + }), + }, + "Map nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.StringVal("boop2"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "List nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("beep"), + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.StringVal("blep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("blep"), + }), + }), + }), + }, + "Set nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "Set nested Map block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + } { + t.Run(n, func(t *testing.T) { + got := setWriteOnlyNullValues(tc.Val, tc.Schema) + + if !got.RawEquals(tc.Expected) { + t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) + } + }) + } +} + func mustMsgpackMarshal(ty cty.Type, val cty.Value) []byte { result, err := msgpack.Marshal(val, ty) From 6cc6811008fc0f2f559524106e01533d7f77458e Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 16 Sep 2024 15:37:43 -0400 Subject: [PATCH 12/32] Apply suggestions from code review Co-authored-by: Austin Valle --- helper/schema/grpc_provider.go | 6 +++--- helper/schema/schema.go | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 8bc0190e64a..436bfafe9fe 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -288,8 +288,8 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req r := s.provider.ResourcesMap[req.TypeName] - // Calling ValidateResourceConfigFunc here since provider.ValidateResource() - // is a public function, so we can't change its signature. + // Calling all ValidateResourceConfigFunc here since they validate on the raw go-cty config value + // and were introduced after the public provider.ValidateResource method. if r.ValidateResourceConfigFuncs != nil { writeOnlyAllowed := false @@ -1956,7 +1956,7 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs if attr.WriteOnly && !v.IsNull() { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), }) } diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 8dde0469029..ce58deef24d 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -2380,8 +2380,7 @@ func (m schemaMap) validateType( return diags } -// hasWriteOnly returns true if the schemaMap contains any -// WriteOnly attributes are set. +// hasWriteOnly returns true if the schemaMap contains any WriteOnly attributes. func (m schemaMap) hasWriteOnly() bool { for _, v := range m { if v.WriteOnly { From 994bc66234d1cda000c17c3aeab651ac430d8fad Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Sep 2024 13:16:28 -0400 Subject: [PATCH 13/32] Wrap `setWriteOnlyNullValues` call in client capabilities check --- helper/schema/grpc_provider.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 436bfafe9fe..3f4041becb1 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -1219,7 +1219,9 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro newStateVal = copyTimeoutValues(newStateVal, plannedStateVal) - newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + if req.ClientCapabilities != nil && req.ClientCapabilities.WriteOnlyAttributesAllowed { + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + } newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil { From ff70638c313a10b43ccbd2ceca30c579ca90ad03 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Sep 2024 13:17:12 -0400 Subject: [PATCH 14/32] Refactor tests to match diag summary changes --- helper/schema/grpc_provider_test.go | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 6e357770b92..c041a57b675 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3579,7 +3579,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", }, }, @@ -3625,12 +3625,12 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, }, @@ -3703,12 +3703,12 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\"", }, }, @@ -7579,12 +7579,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", }, { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", }, }, @@ -7620,7 +7620,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, }, @@ -7662,7 +7662,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", }, }, @@ -7702,12 +7702,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, }, @@ -7749,12 +7749,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, }, @@ -7796,12 +7796,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", }, { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", }, }, @@ -7839,7 +7839,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", }, }, From c5c870d9a9daba49f13294260c82e07fffbba806 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Sep 2024 13:24:51 -0400 Subject: [PATCH 15/32] Move write-only helper functions and tests to their own files. --- helper/schema/grpc_provider.go | 219 ------ helper/schema/grpc_provider_test.go | 1123 -------------------------- helper/schema/write_only.go | 228 ++++++ helper/schema/write_only_test.go | 1133 +++++++++++++++++++++++++++ 4 files changed, 1361 insertions(+), 1342 deletions(-) create mode 100644 helper/schema/write_only.go create mode 100644 helper/schema/write_only_test.go diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 3f4041becb1..13fd33b9d34 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -17,7 +17,6 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/hcl2shim" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" @@ -1941,221 +1940,3 @@ func configureDeferralAllowed(in *tfprotov5.ConfigureProviderClientCapabilities) return in.DeferralAllowed } - -// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an -// error diagnostic for each non-null writeOnly attribute value. -func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { - if !val.IsKnown() || val.IsNull() { - return diag.Diagnostics{} - } - - valMap := val.AsValueMap() - diags := make([]diag.Diagnostic, 0) - - for name, attr := range schema.Attributes { - v := valMap[name] - - if attr.WriteOnly && !v.IsNull() { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), - }) - } - } - - for name, blockS := range schema.BlockTypes { - blockVal := valMap[name] - if blockVal.IsNull() || !blockVal.IsKnown() { - continue - } - - blockValType := blockVal.Type() - - // This switches on the value type here, so we can correctly switch - // between Tuples/Lists and Maps/Objects. - switch { - case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: - // NestingSingle is the only exception here, where we treat the - // block directly as an object - diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): - listVals := blockVal.AsValueSlice() - - for _, v := range listVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) - } - - case blockValType.IsMapType(), blockValType.IsObjectType(): - mapVals := blockVal.AsValueMap() - - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) - } - - default: - panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) - } - } - - return diags -} - -// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an -// error diagnostic for every WriteOnly + Required attribute null value. -func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { - if !val.IsKnown() || val.IsNull() { - return diag.Diagnostics{} - } - - valMap := val.AsValueMap() - diags := make([]diag.Diagnostic, 0) - - for name, attr := range schema.Attributes { - v := valMap[name] - - if attr.WriteOnly && attr.Required && v.IsNull() { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), - }) - } - } - - for name, blockS := range schema.BlockTypes { - blockVal := valMap[name] - if blockVal.IsNull() || !blockVal.IsKnown() { - continue - } - - blockValType := blockVal.Type() - - // This switches on the value type here, so we can correctly switch - // between Tuples/Lists and Maps/Objects. - switch { - case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: - // NestingSingle is the only exception here, where we treat the - // block directly as an object - diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): - listVals := blockVal.AsValueSlice() - - for _, v := range listVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) - } - - case blockValType.IsMapType(), blockValType.IsObjectType(): - mapVals := blockVal.AsValueMap() - - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) - } - - default: - panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) - } - } - - return diags -} - -// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null -// values that are writeOnly to null. -func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { - if !val.IsKnown() || val.IsNull() { - return val - } - - valMap := val.AsValueMap() - newVals := make(map[string]cty.Value) - - for name, attr := range schema.Attributes { - v := valMap[name] - - if attr.WriteOnly && !v.IsNull() { - newVals[name] = cty.NullVal(attr.Type) - continue - } - - newVals[name] = v - } - - for name, blockS := range schema.BlockTypes { - blockVal := valMap[name] - if blockVal.IsNull() || !blockVal.IsKnown() { - newVals[name] = blockVal - continue - } - - blockValType := blockVal.Type() - blockElementType := blockS.Block.ImpliedType() - - // This switches on the value type here, so we can correctly switch - // between Tuples/Lists and Maps/Objects. - switch { - case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: - // NestingSingle is the only exception here, where we treat the - // block directly as an object - newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) - - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): - listVals := blockVal.AsValueSlice() - newListVals := make([]cty.Value, 0, len(listVals)) - - for _, v := range listVals { - newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) - } - - switch { - case blockValType.IsSetType(): - switch len(newListVals) { - case 0: - newVals[name] = cty.SetValEmpty(blockElementType) - default: - newVals[name] = cty.SetVal(newListVals) - } - case blockValType.IsListType(): - switch len(newListVals) { - case 0: - newVals[name] = cty.ListValEmpty(blockElementType) - default: - newVals[name] = cty.ListVal(newListVals) - } - case blockValType.IsTupleType(): - newVals[name] = cty.TupleVal(newListVals) - } - - case blockValType.IsMapType(), blockValType.IsObjectType(): - mapVals := blockVal.AsValueMap() - newMapVals := make(map[string]cty.Value) - - for k, v := range mapVals { - newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) - } - - switch { - case blockValType.IsMapType(): - switch len(newMapVals) { - case 0: - newVals[name] = cty.MapValEmpty(blockElementType) - default: - newVals[name] = cty.MapVal(newMapVals) - } - case blockValType.IsObjectType(): - if len(newMapVals) == 0 { - // We need to populate empty values to make a valid object. - for attr, ty := range blockElementType.AttributeTypes() { - newMapVals[attr] = cty.NullVal(ty) - } - } - newVals[name] = cty.ObjectVal(newMapVals) - } - - default: - panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) - } - } - - return cty.ObjectVal(newVals) -} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index c041a57b675..f7b04a42225 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -21,7 +21,6 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/plugin/convert" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -7489,1128 +7488,6 @@ func Test_pathToAttributePath_noSteps(t *testing.T) { } } -func Test_validateWriteOnlyNullValues(t *testing.T) { - for n, tc := range map[string]struct { - Schema *configschema.Block - Val cty.Value - Expected diag.Diagnostics - }{ - "Empty returns no diags": { - &configschema.Block{}, - cty.EmptyObjectVal, - diag.Diagnostics{}, - }, - "All null values return no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - diag.Diagnostics{}, - }, - "Set nested block WriteOnly attribute with value returns diag": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("foo_val"), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", - }, - }, - }, - "Nested single block, WriteOnly attribute with value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("beep"), - "baz": cty.StringVal("boop"), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, - }, - "Map nested block, WriteOnly attribute with value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, - }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, - }, - "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, - }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.StringVal("boop2"), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, - }, - "List nested block, WriteOnly attribute with dynamic value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.DynamicPseudoType, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.TupleVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NumberIntVal(8), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, - }, - } { - t.Run(n, func(t *testing.T) { - got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) - - if diff := cmp.Diff(got, tc.Expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - -func Test_validateWriteOnlyRequiredValues(t *testing.T) { - for n, tc := range map[string]struct { - Schema *configschema.Block - Val cty.Value - Expected diag.Diagnostics - }{ - "Empty returns no diags": { - &configschema.Block{}, - cty.EmptyObjectVal, - diag.Diagnostics{}, - }, - "All Required + WriteOnly with values return no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.StringVal("blep"), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - "biz": cty.StringVal("boop"), - }), - }), - diag.Diagnostics{}, - }, - "All Optional + WriteOnly with null values return no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - diag.Diagnostics{}, - }, - "Set nested block Required + WriteOnly attribute with null return diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.NullVal(cty.String), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"foo\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"boz\"", - }, - }, - }, - "Nested single block, Required + WriteOnly attribute with null returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("boop"), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", - }, - }, - }, - "Map nested block, Required + WriteOnly attribute with null value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - }, - }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("blep"), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", - }, - }, - }, - "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - }, - }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - }, - }, - } { - t.Run(n, func(t *testing.T) { - got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) - - if diff := cmp.Diff(got, tc.Expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - -func Test_setWriteOnlyNullValues(t *testing.T) { - for n, tc := range map[string]struct { - Schema *configschema.Block - Val cty.Value - Expected cty.Value - }{ - "Empty returns no empty object": { - &configschema.Block{}, - cty.EmptyObjectVal, - cty.EmptyObjectVal, - }, - "Top level attributes and block: write only attributes with values": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - }, - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Required: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.StringVal("blep"), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - "biz": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.NullVal(cty.String), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), - "biz": cty.StringVal("boop"), - }), - }), - }, - "Top level attributes and block: all null values": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - }, - "Set nested block: write only Nested Attribute": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("beep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), - }), - }), - }), - }, - "Nested single block: write only nested attribute": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - }), - }, - "Map nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.StringVal("boop2"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - }, - "List nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("beep"), - "baz": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.StringVal("blep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("blep"), - }), - }), - }), - }, - "Set nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - }), - }), - }, - "Set nested Map block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - }, - } { - t.Run(n, func(t *testing.T) { - got := setWriteOnlyNullValues(tc.Val, tc.Schema) - - if !got.RawEquals(tc.Expected) { - t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) - } - }) - } -} - func mustMsgpackMarshal(ty cty.Type, val cty.Value) []byte { result, err := msgpack.Marshal(val, ty) diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go new file mode 100644 index 00000000000..db62f33f4d1 --- /dev/null +++ b/helper/schema/write_only.go @@ -0,0 +1,228 @@ +package schema + +import ( + "fmt" + + "github.com/hashicorp/go-cty/cty" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" +) + +// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for each non-null writeOnly attribute value. +func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { + if !val.IsKnown() || val.IsNull() { + return diag.Diagnostics{} + } + + valMap := val.AsValueMap() + diags := make([]diag.Diagnostic, 0) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && !v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), + }) + } + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + continue + } + + blockValType := blockVal.Type() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + + for _, v := range listVals { + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + } + + default: + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + } + } + + return diags +} + +// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for every WriteOnly + Required attribute null value. +func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { + if !val.IsKnown() || val.IsNull() { + return diag.Diagnostics{} + } + + valMap := val.AsValueMap() + diags := make([]diag.Diagnostic, 0) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && attr.Required && v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + }) + } + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + continue + } + + blockValType := blockVal.Type() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + + for _, v := range listVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + } + + default: + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + } + } + + return diags +} + +// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null +// values that are writeOnly to null. +func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { + if !val.IsKnown() || val.IsNull() { + return val + } + + valMap := val.AsValueMap() + newVals := make(map[string]cty.Value) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && !v.IsNull() { + newVals[name] = cty.NullVal(attr.Type) + continue + } + + newVals[name] = v + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + newVals[name] = blockVal + continue + } + + blockValType := blockVal.Type() + blockElementType := blockS.Block.ImpliedType() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) + + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + newListVals := make([]cty.Value, 0, len(listVals)) + + for _, v := range listVals { + newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) + } + + switch { + case blockValType.IsSetType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.SetValEmpty(blockElementType) + default: + newVals[name] = cty.SetVal(newListVals) + } + case blockValType.IsListType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.ListValEmpty(blockElementType) + default: + newVals[name] = cty.ListVal(newListVals) + } + case blockValType.IsTupleType(): + newVals[name] = cty.TupleVal(newListVals) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + newMapVals := make(map[string]cty.Value) + + for k, v := range mapVals { + newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) + } + + switch { + case blockValType.IsMapType(): + switch len(newMapVals) { + case 0: + newVals[name] = cty.MapValEmpty(blockElementType) + default: + newVals[name] = cty.MapVal(newMapVals) + } + case blockValType.IsObjectType(): + if len(newMapVals) == 0 { + // We need to populate empty values to make a valid object. + for attr, ty := range blockElementType.AttributeTypes() { + newMapVals[attr] = cty.NullVal(ty) + } + } + newVals[name] = cty.ObjectVal(newMapVals) + } + + default: + panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) + } + } + + return cty.ObjectVal(newVals) +} diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go new file mode 100644 index 00000000000..8620edc5415 --- /dev/null +++ b/helper/schema/write_only_test.go @@ -0,0 +1,1133 @@ +package schema + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-cty/cty" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" +) + +func Test_validateWriteOnlyNullValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected diag.Diagnostics + }{ + "Empty returns no diags": { + &configschema.Block{}, + cty.EmptyObjectVal, + diag.Diagnostics{}, + }, + "All null values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + diag.Diagnostics{}, + }, + "Set nested block WriteOnly attribute with value returns diag": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("foo_val"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", + }, + }, + }, + "Nested single block, WriteOnly attribute with value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("beep"), + "baz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.StringVal("boop2"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with dynamic value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NumberIntVal(8), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + } { + t.Run(n, func(t *testing.T) { + got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) + + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func Test_validateWriteOnlyRequiredValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected diag.Diagnostics + }{ + "Empty returns no diags": { + &configschema.Block{}, + cty.EmptyObjectVal, + diag.Diagnostics{}, + }, + "All Required + WriteOnly with values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.StringVal("blep"), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + "biz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{}, + }, + "All Optional + WriteOnly with null values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + diag.Diagnostics{}, + }, + "Set nested block Required + WriteOnly attribute with null return diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"foo\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"boz\"", + }, + }, + }, + "Nested single block, Required + WriteOnly attribute with null returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, Required + WriteOnly attribute with null value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + }, + }, + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + } { + t.Run(n, func(t *testing.T) { + got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) + + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func Test_setWriteOnlyNullValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected cty.Value + }{ + "Empty returns no empty object": { + &configschema.Block{}, + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }, + "Top level attributes and block: write only attributes with values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.StringVal("blep"), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + "biz": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.NullVal(cty.String), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + "biz": cty.StringVal("boop"), + }), + }), + }, + "Top level attributes and block: all null values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + }, + "Set nested block: write only Nested Attribute": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("beep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + }), + }), + }), + }, + "Nested single block: write only nested attribute": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + }), + }, + "Map nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.StringVal("boop2"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "List nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("beep"), + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.StringVal("blep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("blep"), + }), + }), + }), + }, + "Set nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "Set nested Map block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + } { + t.Run(n, func(t *testing.T) { + got := setWriteOnlyNullValues(tc.Val, tc.Schema) + + if !got.RawEquals(tc.Expected) { + t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) + } + }) + } +} From 21cebbe45782119a106ed71fd89be28e6a590490 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Sep 2024 16:25:59 -0400 Subject: [PATCH 16/32] Refactor test attribute names for clarity --- helper/schema/write_only.go | 166 +++--- helper/schema/write_only_test.go | 925 +++++++++++++++---------------- 2 files changed, 522 insertions(+), 569 deletions(-) diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index db62f33f4d1..cabf94b6d89 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -9,35 +9,36 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" ) -// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an -// error diagnostic for each non-null writeOnly attribute value. -func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { +// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null +// values that are writeOnly to null. +func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { if !val.IsKnown() || val.IsNull() { - return diag.Diagnostics{} + return val } valMap := val.AsValueMap() - diags := make([]diag.Diagnostic, 0) + newVals := make(map[string]cty.Value) for name, attr := range schema.Attributes { v := valMap[name] if attr.WriteOnly && !v.IsNull() { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), - }) + newVals[name] = cty.NullVal(attr.Type) + continue } + + newVals[name] = v } for name, blockS := range schema.BlockTypes { blockVal := valMap[name] if blockVal.IsNull() || !blockVal.IsKnown() { + newVals[name] = blockVal continue } blockValType := blockVal.Type() + blockElementType := blockS.Block.ImpliedType() // This switches on the value type here, so we can correctly switch // between Tuples/Lists and Maps/Objects. @@ -45,32 +46,72 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) + newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() + newListVals := make([]cty.Value, 0, len(listVals)) for _, v := range listVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) + } + + switch { + case blockValType.IsSetType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.SetValEmpty(blockElementType) + default: + newVals[name] = cty.SetVal(newListVals) + } + case blockValType.IsListType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.ListValEmpty(blockElementType) + default: + newVals[name] = cty.ListVal(newListVals) + } + case blockValType.IsTupleType(): + newVals[name] = cty.TupleVal(newListVals) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() + newMapVals := make(map[string]cty.Value) - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + for k, v := range mapVals { + newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) + } + + switch { + case blockValType.IsMapType(): + switch len(newMapVals) { + case 0: + newVals[name] = cty.MapValEmpty(blockElementType) + default: + newVals[name] = cty.MapVal(newMapVals) + } + case blockValType.IsObjectType(): + if len(newMapVals) == 0 { + // We need to populate empty values to make a valid object. + for attr, ty := range blockElementType.AttributeTypes() { + newMapVals[attr] = cty.NullVal(ty) + } + } + newVals[name] = cty.ObjectVal(newMapVals) } default: - panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) } } - return diags + return cty.ObjectVal(newVals) } -// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an -// error diagnostic for every WriteOnly + Required attribute null value. -func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { +// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for each non-null writeOnly attribute value. +func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { return diag.Diagnostics{} } @@ -81,11 +122,11 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con for name, attr := range schema.Attributes { v := valMap[name] - if attr.WriteOnly && attr.Required && v.IsNull() { + if attr.WriteOnly && !v.IsNull() { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + Summary: "WriteOnly Attribute Not Allowed", + Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), }) } } @@ -104,19 +145,19 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) + diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() for _, v := range listVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() for _, v := range mapVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) } default: @@ -127,36 +168,35 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con return diags } -// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null -// values that are writeOnly to null. -func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { +// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for every WriteOnly + Required attribute null value. +func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { - return val + return diag.Diagnostics{} } valMap := val.AsValueMap() - newVals := make(map[string]cty.Value) + diags := make([]diag.Diagnostic, 0) for name, attr := range schema.Attributes { v := valMap[name] - if attr.WriteOnly && !v.IsNull() { - newVals[name] = cty.NullVal(attr.Type) - continue + if attr.WriteOnly && attr.Required && v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + }) } - - newVals[name] = v } for name, blockS := range schema.BlockTypes { blockVal := valMap[name] if blockVal.IsNull() || !blockVal.IsKnown() { - newVals[name] = blockVal continue } blockValType := blockVal.Type() - blockElementType := blockS.Block.ImpliedType() // This switches on the value type here, so we can correctly switch // between Tuples/Lists and Maps/Objects. @@ -164,65 +204,25 @@ func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) - + diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() - newListVals := make([]cty.Value, 0, len(listVals)) for _, v := range listVals { - newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) - } - - switch { - case blockValType.IsSetType(): - switch len(newListVals) { - case 0: - newVals[name] = cty.SetValEmpty(blockElementType) - default: - newVals[name] = cty.SetVal(newListVals) - } - case blockValType.IsListType(): - switch len(newListVals) { - case 0: - newVals[name] = cty.ListValEmpty(blockElementType) - default: - newVals[name] = cty.ListVal(newListVals) - } - case blockValType.IsTupleType(): - newVals[name] = cty.TupleVal(newListVals) + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() - newMapVals := make(map[string]cty.Value) - - for k, v := range mapVals { - newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) - } - switch { - case blockValType.IsMapType(): - switch len(newMapVals) { - case 0: - newVals[name] = cty.MapValEmpty(blockElementType) - default: - newVals[name] = cty.MapVal(newMapVals) - } - case blockValType.IsObjectType(): - if len(newMapVals) == 0 { - // We need to populate empty values to make a valid object. - for attr, ty := range blockElementType.AttributeTypes() { - newMapVals[attr] = cty.NullVal(ty) - } - } - newVals[name] = cty.ObjectVal(newMapVals) + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) } default: - panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) } } - return cty.ObjectVal(newVals) + return diags } diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 8620edc5415..7b8e9dbb417 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -10,42 +10,91 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" ) -func Test_validateWriteOnlyNullValues(t *testing.T) { +func Test_setWriteOnlyNullValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block Val cty.Value - Expected diag.Diagnostics + Expected cty.Value }{ - "Empty returns no diags": { + "Empty returns no empty object": { &configschema.Block{}, cty.EmptyObjectVal, - diag.Diagnostics{}, + cty.EmptyObjectVal, }, - "All null values return no diags": { + "Top level attributes and block: write only attributes with values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_attribute": { + Type: cty.String, + Required: true, + }, + "write_only_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_block": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_block_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "required_block_attribute": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "required_attribute": cty.StringVal("boop"), + "write_only_attribute": cty.StringVal("blep"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("blep"), + "required_block_attribute": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "required_attribute": cty.StringVal("boop"), + "write_only_attribute": cty.NullVal(cty.String), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + "required_block_attribute": cty.StringVal("boop"), + }), + }), + }, + "Top level attributes and block: all null values": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { + "write_only_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "bar": { + "write_only_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "write_only_block_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "biz": { + "write_only_block_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, @@ -56,32 +105,38 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, + "write_only_attribute1": cty.String, + "write_only_attribute2": cty.String, + "nested_block": cty.Object(map[string]cty.Type{ + "write_only_block_attribute1": cty.String, + "write_only_block_attribute2": cty.String, + }), + })), + cty.NullVal(cty.Object(map[string]cty.Type{ + "write_only_attribute1": cty.String, + "write_only_attribute2": cty.String, + "nested_block": cty.Object(map[string]cty.Type{ + "write_only_block_attribute1": cty.String, + "write_only_block_attribute2": cty.String, }), })), - diag.Diagnostics{}, }, - "Set nested block WriteOnly attribute with value returns diag": { + "Set nested block: write only Nested Attribute": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, + "required_attribute": { + Type: cty.String, + Required: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "write_only_block_attribute": { Type: cty.String, - Optional: true, + Required: true, WriteOnly: true, }, }, @@ -90,39 +145,35 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("foo_val"), - "baz": cty.SetVal([]cty.Value{ + "required_attribute": cty.StringVal("boop"), + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("beep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "required_attribute": cty.StringVal("boop"), + "set_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", - }, - }, }, - "Nested single block, WriteOnly attribute with value returns diag": { + "Nested single block: write only nested attribute": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, - Optional: true, + Required: true, WriteOnly: true, }, - "baz": { + "optional_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -133,34 +184,33 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("beep"), - "baz": cty.StringVal("boop"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("boop"), + "optional_attribute": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_attribute": cty.StringVal("boop"), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, }, - "Map nested block, WriteOnly attribute with value returns diag": { + "Map nested block: multiple write only nested attributes": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "write_only_block_attribute": { Type: cty.String, - Optional: true, + Required: true, WriteOnly: true, }, }, @@ -169,38 +219,43 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.StringVal("boop"), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("boop2"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.NullVal(cty.String), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + "List nested block: multiple write only nested attributes": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "list_block": { Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, - Optional: true, + Required: true, WriteOnly: true, }, - "baz": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -211,172 +266,89 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("bap"), + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute": cty.StringVal("bap"), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("boop"), + "optional_block_attribute": cty.StringVal("blep"), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, - }, - "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("bap"), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + "Set nested block: multiple write only nested attributes": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, + "set_block": { + Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { + "write_only_block_attribute": { Type: cty.String, Optional: true, WriteOnly: true, }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, }, }, }, }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), + "set_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("blep"), + "optional_block_attribute": cty.NullVal(cty.String), }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.StringVal("boop2"), + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("boop"), + "optional_block_attribute": cty.StringVal("boop"), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, - }, - "List nested block, WriteOnly attribute with dynamic value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.DynamicPseudoType, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.TupleVal([]cty.Value{ + "set_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.NullVal(cty.String), + }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NumberIntVal(8), + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("boop"), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) + got := setWriteOnlyNullValues(tc.Val, tc.Schema) - if diff := cmp.Diff(got, tc.Expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) + if !got.RawEquals(tc.Expected) { + t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) } }) } } -func Test_validateWriteOnlyRequiredValues(t *testing.T) { +func Test_validateWriteOnlyNullValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block Val cty.Value @@ -387,75 +359,31 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { cty.EmptyObjectVal, diag.Diagnostics{}, }, - "All Required + WriteOnly with values return no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.StringVal("blep"), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - "biz": cty.StringVal("boop"), - }), - }), - diag.Diagnostics{}, - }, - "All Optional + WriteOnly with null values return no diags": { + "All null values return no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { + "write_only_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "bar": { + "write_only_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "single_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "write_only_block_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "biz": { + "write_only_block_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, @@ -466,32 +394,32 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, + "write_only_attribute1": cty.String, + "write_only_attribute2": cty.String, + "single_block": cty.Object(map[string]cty.Type{ + "write_only_block_attribute1": cty.String, + "write_only_block_attribute2": cty.String, }), })), diag.Diagnostics{}, }, - "Set nested block Required + WriteOnly attribute with null return diags": { + "Set nested block WriteOnly attribute with value returns diag": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { + "write_only_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, @@ -500,39 +428,39 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.NullVal(cty.String), - "baz": cty.SetVal([]cty.Value{ + "write_only_attribute": cty.StringVal("val"), + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), + "write_only_block_attribute": cty.StringVal("block_val"), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"foo\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_attribute\"", }, { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"boz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, - "Nested single block, Required + WriteOnly attribute with null returns diag": { + "Nested single block, WriteOnly attribute with value returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, - "baz": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -543,33 +471,34 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("boop"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute1": cty.StringVal("boop"), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, - "Map nested block, Required + WriteOnly attribute with null value returns diag": { + "Map nested block, WriteOnly attribute with value returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, @@ -578,38 +507,38 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), + "optional_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.StringVal("boop"), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.NullVal(cty.String), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, "List nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "list_block": { Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, - "baz": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -620,43 +549,43 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("bap"), + "write_only_block_attribute": cty.StringVal("bap"), }), cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("blep"), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "baz": { + "write_only_block_attribute2": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, @@ -665,45 +594,45 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "write_only_block_attribute1": cty.StringVal("blep"), + "write_only_block_attribute2": cty.NullVal(cty.String), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), + "write_only_block_attribute1": cty.StringVal("boop"), + "write_only_block_attribute2": cty.NullVal(cty.String), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\"", }, { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\"", }, }, }, "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, @@ -712,33 +641,71 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.StringVal("boop"), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("boop2"), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + }, + }, + }, + "List nested block, WriteOnly attribute with dynamic value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "list_block": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "write_only_block_attribute": { + Type: cty.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "list_block": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NumberIntVal(8), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) + got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) if diff := cmp.Diff(got, tc.Expected); diff != "" { t.Errorf("unexpected difference: %s", diff) @@ -747,43 +714,45 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { } } -func Test_setWriteOnlyNullValues(t *testing.T) { +func Test_validateWriteOnlyRequiredValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block Val cty.Value - Expected cty.Value + Expected diag.Diagnostics }{ - "Empty returns no empty object": { + "Empty returns no diags": { &configschema.Block{}, cty.EmptyObjectVal, - cty.EmptyObjectVal, + diag.Diagnostics{}, }, - "Top level attributes and block: write only attributes with values": { + "All Required + WriteOnly with values return no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, + "required_write_only_attribute1": { + Type: cty.String, + Required: true, + WriteOnly: true, }, - "bar": { + "required_write_only_attribute2": { Type: cty.String, Required: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "required_write_only_block_attribute1": { Type: cty.String, Required: true, WriteOnly: true, }, - "biz": { - Type: cty.String, - Required: true, + "required_write_only_block_attribute2": { + Type: cty.String, + Required: true, + WriteOnly: true, }, }, }, @@ -791,47 +760,40 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.StringVal("blep"), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - "biz": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.NullVal(cty.String), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), - "biz": cty.StringVal("boop"), + "required_write_only_attribute1": cty.StringVal("boop"), + "required_write_only_attribute2": cty.StringVal("blep"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute1": cty.StringVal("blep"), + "required_write_only_block_attribute2": cty.StringVal("boop"), }), }), + diag.Diagnostics{}, }, - "Top level attributes and block: all null values": { + "All Optional + WriteOnly with null values return no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { + "optional_write_only_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "bar": { + "optional_write_only_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "optional_write_only_block_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "biz": { + "optional_write_only_block_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, @@ -842,36 +804,30 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, + "optional_write_only_attribute1": cty.String, + "optional_write_only_attribute2": cty.String, + "nested_block": cty.Object(map[string]cty.Type{ + "optional_write_only_block_attribute1": cty.String, + "optional_write_only_block_attribute2": cty.String, }), })), + diag.Diagnostics{}, }, - "Set nested block: write only Nested Attribute": { + "Set nested block Required + WriteOnly attribute with null return diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, + "required_write_only_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, @@ -882,35 +838,39 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "baz": cty.SetVal([]cty.Value{ + "required_write_only_attribute": cty.NullVal(cty.String), + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("beep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, - "Nested single block: write only nested attribute": { + "Nested single block, Required + WriteOnly attribute with null returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, }, - "baz": { + "optional_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -921,31 +881,31 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "optional_attribute": cty.StringVal("boop"), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", + }, + }, }, - "Map nested block: multiple write only nested attributes": { + "Map nested block, Required + WriteOnly attribute with null value returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, @@ -956,43 +916,38 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.StringVal("boop2"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.NullVal(cty.String), + "required_write_only_block_attribute": cty.StringVal("boop"), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, - "List nested block: multiple write only nested attributes": { + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "list_block": { Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, }, - "baz": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -1003,43 +958,41 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("beep"), - "baz": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.StringVal("blep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("bap"), + "optional_block_attribute": cty.StringVal("bap"), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("blep"), + "optional_block_attribute": cty.StringVal("blep"), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, - "Set nested block: multiple write only nested attributes": { + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_write_only_block_attribute": { Type: cty.String, Optional: true, WriteOnly: true, }, - "baz": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, @@ -1050,43 +1003,43 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_write_only_block_attribute": cty.StringVal("blep"), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_write_only_block_attribute": cty.StringVal("boop"), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, - "Set nested Map block: multiple write only nested attributes": { + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, @@ -1097,36 +1050,36 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.NullVal(cty.String), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, } { t.Run(n, func(t *testing.T) { - got := setWriteOnlyNullValues(tc.Val, tc.Schema) + got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) - if !got.RawEquals(tc.Expected) { - t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) } }) } From a276ff4f7b279d3c32be636df7262a86218a9fdf Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 14:28:18 -0400 Subject: [PATCH 17/32] Refactor `validateWriteOnlyNullValues()` to build an attribute path.` --- helper/schema/grpc_provider.go | 2 +- helper/schema/write_only.go | 60 ++++++++-- helper/schema/write_only_test.go | 196 +++++++++++++++++++++++++++---- 3 files changed, 223 insertions(+), 35 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 13fd33b9d34..51b83732d30 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -282,7 +282,7 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req return resp, nil } if req.ClientCapabilities == nil || !req.ClientCapabilities.WriteOnlyAttributesAllowed { - resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock)) + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock, cty.Path{})) } r := s.provider.ResourcesMap[req.TypeName] diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index cabf94b6d89..7a375e0b64f 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -2,6 +2,7 @@ package schema import ( "fmt" + "sort" "github.com/hashicorp/go-cty/cty" @@ -109,9 +110,13 @@ func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value return cty.ObjectVal(newVals) } -// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an +// validateWriteOnlyNullValues validates that write-only attribute values +// are null to ensure that write-only values are not sent to unsupported +// Terraform client versions. +// +// it takes a cty.Value, and compares it to the schema and throws an // error diagnostic for each non-null writeOnly attribute value. -func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { +func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block, path cty.Path) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { return diag.Diagnostics{} } @@ -119,25 +124,42 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs valMap := val.AsValueMap() diags := make([]diag.Diagnostic, 0) - for name, attr := range schema.Attributes { + var attrNames []string + for k := range schema.Attributes { + attrNames = append(attrNames, k) + } + sort.Strings(attrNames) + + for _, name := range attrNames { + attr := schema.Attributes[name] v := valMap[name] if attr.WriteOnly && !v.IsNull() { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), + Detail: fmt.Sprintf("The resource contains a non-null value for WriteOnly attribute %q ", name) + + fmt.Sprintf("Write-only attributes are only supported in Terraform 1.11 and later."), + AttributePath: append(path, cty.GetAttrStep{Name: name}), }) } } - for name, blockS := range schema.BlockTypes { + var blockNames []string + for k := range schema.BlockTypes { + blockNames = append(blockNames, k) + } + sort.Strings(blockNames) + + for _, name := range blockNames { + blockS := schema.BlockTypes[name] blockVal := valMap[name] if blockVal.IsNull() || !blockVal.IsKnown() { continue } blockValType := blockVal.Type() + blockPath := append(path, cty.GetAttrStep{Name: name}) // This switches on the value type here, so we can correctly switch // between Tuples/Lists and Maps/Objects. @@ -145,19 +167,35 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block, blockPath)...) + case blockValType.IsSetType(): + setVals := blockVal.AsValueSlice() + + for _, v := range setVals { + setBlockPath := append(blockPath, cty.IndexStep{ + Key: v, + }) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, setBlockPath)...) + } + + case blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() - for _, v := range listVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + for i, v := range listVals { + listBlockPath := append(blockPath, cty.IndexStep{ + Key: cty.NumberIntVal(int64(i)), + }) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, listBlockPath)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + for k, v := range mapVals { + mapBlockPath := append(blockPath, cty.IndexStep{ + Key: cty.StringVal(k), + }) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, mapBlockPath)...) } default: diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 7b8e9dbb417..e0893805aed 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -439,12 +439,24 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "write_only_attribute"}, + }, }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("block_val"), + })}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -480,7 +492,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block"}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -522,7 +539,13 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -562,12 +585,24 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(1)}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -609,12 +644,30 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute1": cty.StringVal("blep"), + "write_only_block_attribute2": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "write_only_block_attribute1"}, + }, }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute1": cty.StringVal("boop"), + "write_only_block_attribute2": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "write_only_block_attribute1"}, + }, }, }, }, @@ -656,12 +709,24 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("b")}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -699,15 +764,96 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, + }, + }, + }, + "multiple nested blocks, multiple WriteOnly attributes with value returns diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_block1": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_block_attribute": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + "nested_block2": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_block_attribute": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "nested_block1": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute1": cty.StringVal("boop"), + }), + "nested_block2": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute1": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block1"}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block2"}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) + got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema, cty.Path{}) - if diff := cmp.Diff(got, tc.Expected); diff != "" { + if diff := cmp.Diff(got, tc.Expected, + cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), + cmp.Comparer(indexStepComparer)); diff != "" { t.Errorf("unexpected difference: %s", diff) } }) @@ -849,12 +995,12 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -889,7 +1035,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", }, }, }, @@ -931,7 +1077,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -971,12 +1117,12 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -1018,12 +1164,12 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -1065,12 +1211,12 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -1084,3 +1230,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }) } } + +func indexStepComparer(step cty.IndexStep, other cty.IndexStep) bool { + return true +} From fac41b53a8ae4c8196d03a7e73ff3bece70a9e76 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 15:12:11 -0400 Subject: [PATCH 18/32] Refactor `validateWriteOnlyRequiredValues()` to build an attribute path.` --- helper/schema/grpc_provider.go | 2 +- helper/schema/grpc_provider_test.go | 30 +- helper/schema/write_only.go | 61 +++- helper/schema/write_only_test.go | 437 ++++++++++++++++++++++------ 4 files changed, 414 insertions(+), 116 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 51b83732d30..5abb03d4359 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -847,7 +847,7 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot // If the resource is being created, validate that all required write-only // attributes in the config have non-nil values. if create { - diags := validateWriteOnlyRequiredValues(req.TypeName, configVal, schemaBlock) + diags := validateWriteOnlyRequiredValues(req.TypeName, configVal, schemaBlock, cty.Path{}) if diags.HasError() { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, diags) return resp, nil diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index f7b04a42225..e620c970737 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3579,7 +3579,9 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"foo\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, }, }, @@ -3625,12 +3627,16 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"bar\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath().WithAttributeName("bar"), }, { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"foo\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, }, }, @@ -3703,12 +3709,19 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"foo\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath(). + WithAttributeName("config_block_attr"). + WithElementKeyInt(0). + WithAttributeName("writeonly_nested_attr"), }, }, }, @@ -5069,9 +5082,10 @@ func TestPlanResourceChange(t *testing.T) { expected: &tfprotov5.PlanResourceChangeResponse{ Diagnostics: []*tfprotov5.Diagnostic{ { - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test\" resource contains a null value for Required WriteOnly attribute \"foo\"", + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"foo\"", + Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, }, UnsafeToUseLegacyTypeSystem: true, diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index 7a375e0b64f..a87db12cd89 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -128,6 +128,8 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs for k := range schema.Attributes { attrNames = append(attrNames, k) } + + // Sort the attribute names to produce diags in a consistent order. sort.Strings(attrNames) for _, name := range attrNames { @@ -149,6 +151,8 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs for k := range schema.BlockTypes { blockNames = append(blockNames, k) } + + // Sort the block names to produce diags in a consistent order. sort.Strings(blockNames) for _, name := range blockNames { @@ -208,7 +212,7 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs // validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an // error diagnostic for every WriteOnly + Required attribute null value. -func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { +func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block, path cty.Path) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { return diag.Diagnostics{} } @@ -216,25 +220,44 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con valMap := val.AsValueMap() diags := make([]diag.Diagnostic, 0) + var attrNames []string + for k := range schema.Attributes { + attrNames = append(attrNames, k) + } + + // Sort the attribute names to produce diags in a consistent order. + sort.Strings(attrNames) + for name, attr := range schema.Attributes { v := valMap[name] if attr.WriteOnly && attr.Required && v.IsNull() { diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: fmt.Sprintf("The resource contains a null value for Required WriteOnly attribute %q", name), + AttributePath: append(path, cty.GetAttrStep{Name: name}), }) } } - for name, blockS := range schema.BlockTypes { + var blockNames []string + for k := range schema.BlockTypes { + blockNames = append(blockNames, k) + } + + // Sort the block names to produce diags in a consistent order. + sort.Strings(blockNames) + + for _, name := range blockNames { + blockS := schema.BlockTypes[name] blockVal := valMap[name] if blockVal.IsNull() || !blockVal.IsKnown() { continue } blockValType := blockVal.Type() + blockPath := append(path, cty.GetAttrStep{Name: name}) // This switches on the value type here, so we can correctly switch // between Tuples/Lists and Maps/Objects. @@ -242,19 +265,35 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block, blockPath)...) + case blockValType.IsSetType(): + setVals := blockVal.AsValueSlice() + + for _, v := range setVals { + setBlockPath := append(blockPath, cty.IndexStep{ + Key: v, + }) + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, setBlockPath)...) + } + + case blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() - for _, v := range listVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + for i, v := range listVals { + listBlockPath := append(blockPath, cty.IndexStep{ + Key: cty.NumberIntVal(int64(i)), + }) + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, listBlockPath)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + for k, v := range mapVals { + mapBlockPath := append(blockPath, cty.IndexStep{ + Key: cty.StringVal(k), + }) + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, mapBlockPath)...) } default: diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index e0893805aed..03889af6158 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -359,7 +359,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { cty.EmptyObjectVal, diag.Diagnostics{}, }, - "All null values return no diags": { + "All null values returns no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "write_only_attribute1": { @@ -460,11 +460,18 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, }, - "Nested single block, WriteOnly attribute with value returns diag": { + "List nested block, WriteOnly attribute with value returns diag": { &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_attribute": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, BlockTypes: map[string]*configschema.NestedBlock{ - "nested_block": { - Nesting: configschema.NestingSingle, + "list_block": { + Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ "write_only_block_attribute": { @@ -483,19 +490,31 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "nested_block": cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("beep"), - "optional_block_attribute1": cty.StringVal("boop"), + "write_only_attribute": cty.StringVal("val"), + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("bap"), + }), }), }), diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "write_only_attribute"}, + }, + }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "nested_block"}, + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, @@ -549,11 +568,11 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + "Nested single block, WriteOnly attribute with value returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "list_block": { - Nesting: configschema.NestingList, + "nested_block": { + Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ "write_only_block_attribute": { @@ -572,13 +591,9 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "list_block": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("blep"), - }), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute1": cty.StringVal("boop"), }), }), diag.Diagnostics{ @@ -588,19 +603,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "list_block"}, - cty.IndexStep{Key: cty.NumberIntVal(0)}, - cty.GetAttrStep{Name: "write_only_block_attribute"}, - }, - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + - "Write-only attributes are only supported in Terraform 1.11 and later.", - AttributePath: cty.Path{ - cty.GetAttrStep{Name: "list_block"}, - cty.IndexStep{Key: cty.NumberIntVal(1)}, + cty.GetAttrStep{Name: "nested_block"}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, @@ -671,37 +674,35 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "map_block": { - Nesting: configschema.NestingMap, + "list_block": { + Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "optional_block_attribute": { - Type: cty.String, - Optional: true, - Computed: true, - }, "write_only_block_attribute": { Type: cty.String, Optional: true, WriteOnly: true, }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, }, }, }, }, }, cty.ObjectVal(map[string]cty.Value{ - "map_block": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.NullVal(cty.String), - "write_only_block_attribute": cty.StringVal("boop"), + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("bap"), }), - "b": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("blep"), - "write_only_block_attribute": cty.StringVal("boop2"), + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("blep"), }), }), }), @@ -712,8 +713,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "map_block"}, - cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, @@ -723,18 +724,18 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "map_block"}, - cty.IndexStep{Key: cty.StringVal("b")}, + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(1)}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, }, }, - "List nested block, WriteOnly attribute with dynamic value returns diag": { + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "list_block": { - Nesting: configschema.NestingList, + "map_block": { + Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ "optional_block_attribute": { @@ -743,7 +744,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Computed: true, }, "write_only_block_attribute": { - Type: cty.DynamicPseudoType, + Type: cty.String, Optional: true, WriteOnly: true, }, @@ -753,10 +754,14 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "list_block": cty.TupleVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ "optional_block_attribute": cty.NullVal(cty.String), - "write_only_block_attribute": cty.NumberIntVal(8), + "write_only_block_attribute": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("boop2"), }), }), }), @@ -767,14 +772,25 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "list_block"}, - cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("b")}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, }, }, - "multiple nested blocks, multiple WriteOnly attributes with value returns diags": { + "Nested single block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ "nested_block1": { @@ -847,6 +863,50 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, }, + "List nested block, WriteOnly attribute with dynamic value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "list_block": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "write_only_block_attribute": { + Type: cty.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "list_block": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NumberIntVal(8), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, + }, + }, + }, } { t.Run(n, func(t *testing.T) { got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema, cty.Path{}) @@ -871,7 +931,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { cty.EmptyObjectVal, diag.Diagnostics{}, }, - "All Required + WriteOnly with values return no diags": { + "All Required + WriteOnly with values returns no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "required_write_only_attribute1": { @@ -915,7 +975,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }), diag.Diagnostics{}, }, - "All Optional + WriteOnly with null values return no diags": { + "All Optional + WriteOnly with null values returns no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "optional_write_only_attribute1": { @@ -959,7 +1019,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { })), diag.Diagnostics{}, }, - "Set nested block Required + WriteOnly attribute with null return diags": { + "Set nested block Required + WriteOnly attribute with null returns diag": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "required_write_only_attribute": { @@ -996,27 +1056,44 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "required_write_only_attribute"}, + }, }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, }, }, - "Nested single block, Required + WriteOnly attribute with null returns diag": { + "List nested block Required + WriteOnly attribute with null returns diag": { &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_write_only_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, BlockTypes: map[string]*configschema.NestedBlock{ - "nested_block": { - Nesting: configschema.NestingSingle, + "list_block": { + Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "write_only_block_attribute": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, }, - "optional_attribute": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -1027,15 +1104,31 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "nested_block": cty.ObjectVal(map[string]cty.Value{ - "optional_attribute": cty.StringVal("boop"), + "required_write_only_attribute": cty.NullVal(cty.String), + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute": cty.NullVal(cty.String), + }), }), }), diag.Diagnostics{ { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "required_write_only_attribute"}, + }, + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, }, }, @@ -1078,22 +1171,27 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("b")}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, }, }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + "Nested single block, Required + WriteOnly attribute with null returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "list_block": { - Nesting: configschema.NestingList, + "nested_block": { + Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "required_write_only_block_attribute": { + "write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, }, - "optional_block_attribute": { + "optional_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -1104,25 +1202,19 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "list_block": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("blep"), - }), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "optional_attribute": cty.StringVal("boop"), }), }), diag.Diagnostics{ { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block"}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -1165,26 +1257,97 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "optional_write_only_block_attribute": cty.StringVal("blep"), + "required_write_only_block_attribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "optional_write_only_block_attribute": cty.StringVal("boop"), + "required_write_only_block_attribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, }, }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "map_block": { - Nesting: configschema.NestingMap, + "list_block": { + Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ + "required_write_only_block_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(1)}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "map_block": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "optional_write_only_block_attribute": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, "required_write_only_block_attribute": { Type: cty.String, Required: true, @@ -1198,11 +1361,11 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { cty.ObjectVal(map[string]cty.Value{ "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.NullVal(cty.String), + "optional_write_only_block_attribute": cty.NullVal(cty.String), "required_write_only_block_attribute": cty.NullVal(cty.String), }), "b": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("blep"), + "optional_write_only_block_attribute": cty.StringVal("blep"), "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), @@ -1212,19 +1375,101 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("b")}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, + }, + }, + }, + "Nested single block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_block1": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_write_only_block_attribute1": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "optional_write_only_block_attribute1": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + "nested_block2": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_write_only_block_attribute2": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "optional_write_only_block_attribute2": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "nested_block1": cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute1": cty.NullVal(cty.String), + "optional_write_only_block_attribute1": cty.StringVal("boop"), + }), + "nested_block2": cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute2": cty.NullVal(cty.String), + "optional_write_only_block_attribute2": cty.NullVal(cty.String), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute1\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block1"}, + cty.GetAttrStep{Name: "required_write_only_block_attribute1"}, + }, + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute2\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block2"}, + cty.GetAttrStep{Name: "required_write_only_block_attribute2"}, + }, }, }, }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) + got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema, cty.Path{}) - if diff := cmp.Diff(got, tc.Expected); diff != "" { + if diff := cmp.Diff(got, tc.Expected, + cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), + cmp.Comparer(indexStepComparer)); diff != "" { t.Errorf("unexpected difference: %s", diff) } }) From 7cf90244351c2df02f7e51add906b669fcd58156 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 15:22:52 -0400 Subject: [PATCH 19/32] Refactor field and function names based on PR feedback. --- helper/schema/grpc_provider.go | 6 ++--- helper/schema/grpc_provider_test.go | 38 ++++++++++++++--------------- helper/schema/resource.go | 15 +++++++----- helper/validation/path.go | 4 +-- helper/validation/path_test.go | 6 ++--- helper/validation/write_only.go | 6 ++--- 6 files changed, 39 insertions(+), 36 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 5abb03d4359..a49e5a41653 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -287,9 +287,9 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req r := s.provider.ResourcesMap[req.TypeName] - // Calling all ValidateResourceConfigFunc here since they validate on the raw go-cty config value + // Calling all ValidateRawResourceConfigFunc here since they validate on the raw go-cty config value // and were introduced after the public provider.ValidateResource method. - if r.ValidateResourceConfigFuncs != nil { + if r.ValidateRawResourceConfigFuncs != nil { writeOnlyAllowed := false if req.ClientCapabilities != nil { @@ -301,7 +301,7 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req RawConfig: configVal, } - for _, validateFunc := range r.ValidateResourceConfigFuncs { + for _, validateFunc := range r.ValidateRawResourceConfigFuncs { validateResp := &ValidateResourceConfigFuncResponse{} validateFunc(ctx, validateReq, validateResp) resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateResp.Diagnostics) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index e620c970737..954f23aef7e 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3514,7 +3514,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { }, expected: &tfprotov5.ValidateResourceTypeConfigResponse{}, }, - "Server without WriteOnlyAttributesAllowed capabilities: null WriteOnly attribute returns no errors": { + "Client without WriteOnlyAttributesAllowed capabilities: null WriteOnly attribute returns no errors": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test_resource": { @@ -3726,17 +3726,17 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { }, }, }, - "Server with ValidateResourceConfigFunc: WriteOnlyAttributesAllowed true returns diags": { + "Server with ValidateRawResourceConfigFunc: WriteOnlyAttributesAllowed true returns diags": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test_resource": { - ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { if req.WriteOnlyAttributesAllowed { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3746,7 +3746,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3790,26 +3790,26 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, }, }, }, - "Server with ValidateResourceConfigFunc: WriteOnlyAttributesAllowed false returns diags": { + "Server with ValidateRawResourceConfigFunc: WriteOnlyAttributesAllowed false returns diags": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test_resource": { - ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3819,7 +3819,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3859,20 +3859,20 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, }, }, }, - "Server with ValidateResourceConfigFunc: equal config value returns diags": { + "Server with ValidateRawResourceConfigFunc: equal config value returns diags": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test_resource": { - ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { equals := req.RawConfig.Equals(cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -3883,7 +3883,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3898,7 +3898,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3938,11 +3938,11 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, }, }, diff --git a/helper/schema/resource.go b/helper/schema/resource.go index dd52d1ca777..0d46c3aac87 100644 --- a/helper/schema/resource.go +++ b/helper/schema/resource.go @@ -645,15 +645,18 @@ type Resource struct { // interacting with this resource. ResourceBehavior ResourceBehavior - // ValidateResourceConfigFuncs allows functions to define arbitrary validation - // logic during the ValidateResourceTypeConfig RPC. ValidateResourceConfigFunc receives + // ValidateRawResourceConfigFuncs allows functions to define arbitrary validation + // logic during the ValidateResourceTypeConfig RPC. ValidateRawResourceConfigFunc receives // the client capabilities from the ValidateResourceTypeConfig RPC and the raw cty // config value for the entire resource before it is shimmed, and it can return error // diagnostics based on the inspection of those values. // - // ValidateResourceConfigFuncs is only valid for Managed Resource types and will not be + // ValidateRawResourceConfigFuncs is only valid for Managed Resource types and will not be // called for Data Resource or Block types. - ValidateResourceConfigFuncs []ValidateResourceConfigFunc + // + // Developers should prefer other validation methods first as this validation function + // deals with raw cty values. + ValidateRawResourceConfigFuncs []ValidateRawResourceConfigFunc } // ResourceBehavior controls SDK-specific logic when interacting @@ -680,10 +683,10 @@ type ProviderDeferredBehavior struct { EnablePlanModification bool } -// ValidateResourceConfigFunc is a function used to validate the raw resource config +// ValidateRawResourceConfigFunc is a function used to validate the raw resource config // and has Diagnostic support. it is only valid for Managed Resource types and will not be // called for Data Resource or Block types. -type ValidateResourceConfigFunc func(context.Context, ValidateResourceConfigFuncRequest, *ValidateResourceConfigFuncResponse) +type ValidateRawResourceConfigFunc func(context.Context, ValidateResourceConfigFuncRequest, *ValidateResourceConfigFuncResponse) type ValidateResourceConfigFuncRequest struct { // WriteOnlyAttributesAllowed indicates that the Terraform client diff --git a/helper/validation/path.go b/helper/validation/path.go index b17449a3d5a..b8707330d01 100644 --- a/helper/validation/path.go +++ b/helper/validation/path.go @@ -7,10 +7,10 @@ import ( "github.com/hashicorp/go-cty/cty" ) -// PathEquals compares two Paths for equality. For cty.IndexStep, +// PathMatches compares two Paths for equality. For cty.IndexStep, // unknown key values are treated as an Any qualifier and will // match any index step of the same type. -func PathEquals(p cty.Path, other cty.Path) bool { +func PathMatches(p cty.Path, other cty.Path) bool { if len(p) != len(other) { return false } diff --git a/helper/validation/path_test.go b/helper/validation/path_test.go index aabb2dc281a..b85b837ed68 100644 --- a/helper/validation/path_test.go +++ b/helper/validation/path_test.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/go-cty/cty" ) -func TestPathEquals(t *testing.T) { +func TestPathMatches(t *testing.T) { tests := map[string]struct { p cty.Path other cty.Path @@ -108,8 +108,8 @@ func TestPathEquals(t *testing.T) { } for name, tc := range tests { t.Run(name, func(t *testing.T) { - if got := PathEquals(tc.p, tc.other); got != tc.want { - t.Errorf("PathEquals() = %v, want %v", got, tc.want) + if got := PathMatches(tc.p, tc.other); got != tc.want { + t.Errorf("PathMatches() = %v, want %v", got, tc.want) } }) } diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go index 1fa9636814f..23832d5ae45 100644 --- a/helper/validation/write_only.go +++ b/helper/validation/write_only.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -// PreferWriteOnlyAttribute is a ValidateResourceConfigFunc that returns a warning +// PreferWriteOnlyAttribute is a ValidateRawResourceConfigFunc that returns a warning // if the Terraform client supports write-only attributes and the old attribute is // not null. // The last step in the path must be a cty.GetAttrStep{}. @@ -22,7 +22,7 @@ import ( // For lists: cty.Index(cty.UnknownVal(cty.Number)), // For maps: cty.Index(cty.UnknownVal(cty.String)), // For sets: cty.Index(cty.UnknownVal(cty.Object(nil))), -func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName string) schema.ValidateResourceConfigFunc { +func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName string) schema.ValidateRawResourceConfigFunc { return func(ctx context.Context, req schema.ValidateResourceConfigFuncRequest, resp *schema.ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { return @@ -31,7 +31,7 @@ func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName stri var oldAttrs []attribute err := cty.Walk(req.RawConfig, func(path cty.Path, value cty.Value) (bool, error) { - if PathEquals(path, oldAttribute) { + if PathMatches(path, oldAttribute) { oldAttrs = append(oldAttrs, attribute{ value: value, path: path, From d3a4926810204f5389d637f091aed1f5e7566c05 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 15:33:35 -0400 Subject: [PATCH 20/32] Add clarifying comments. --- helper/schema/schema.go | 6 +++++- internal/configs/configschema/schema.go | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index ce58deef24d..80a3d8ecb57 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -399,10 +399,12 @@ type Schema struct { // WriteOnly indicates that the practitioner can choose a value for this // attribute, but Terraform will not store this attribute in state. // If WriteOnly is true, either Optional or Required must also be true. + // If an attribute is Required and WriteOnly, an attribute value + // is only required on resource creation. // // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. // - // This functionality is only supported in Terraform 1.XX and later. TODO: Add Terraform version + // This functionality is only supported in Terraform 1.11 and later. // Practitioners that choose a value for this attribute with older // versions of Terraform will receive an error. WriteOnly bool @@ -1725,6 +1727,8 @@ func (m schemaMap) validate( } if !ok { + // We don't validate required + writeOnly attributes here + // as that is done in PlanResourceChange (only on create). if schema.Required && !schema.WriteOnly { return append(diags, diag.Diagnostic{ Severity: diag.Error, diff --git a/internal/configs/configschema/schema.go b/internal/configs/configschema/schema.go index a45c5cc241d..bf29acab63d 100644 --- a/internal/configs/configschema/schema.go +++ b/internal/configs/configschema/schema.go @@ -87,10 +87,12 @@ type Attribute struct { // WriteOnly indicates that the practitioner can choose a value for this // attribute, but Terraform will not store this attribute in state. // If WriteOnly is true, either Optional or Required must also be true. + // If an attribute is Required and WriteOnly, an attribute value + // is only required on resource creation. // // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. // - // This functionality is only supported in Terraform 1.XX and later. TODO: add Terraform version + // This functionality is only supported in Terraform 1.11 and later. // Practitioners that choose a value for this attribute with older // versions of Terraform will receive an error. WriteOnly bool From 437577d4f37f14cbb660218efecbe01269d63a55 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 15:47:55 -0400 Subject: [PATCH 21/32] Add internal validation preventing data sources from defining `ValidateRawResourceConfigFuncs` --- helper/schema/provider.go | 4 +++ helper/schema/provider_test.go | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/helper/schema/provider.go b/helper/schema/provider.go index 498d0f55429..d60e2d3764e 100644 --- a/helper/schema/provider.go +++ b/helper/schema/provider.go @@ -228,6 +228,10 @@ func (p *Provider) InternalValidate() error { validationErrors = append(validationErrors, fmt.Errorf("data source %s: %s", k, err)) } + if len(r.ValidateRawResourceConfigFuncs) > 0 { + validationErrors = append(validationErrors, fmt.Errorf("data source %s cannot contain ValidateRawResourceConfigFuncs", k)) + } + dataSourceSchema := schemaMap(r.SchemaMap()) if dataSourceSchema.hasWriteOnly() { validationErrors = append(validationErrors, fmt.Errorf("data source %s cannot contain WriteOnly attributes", k)) diff --git a/helper/schema/provider_test.go b/helper/schema/provider_test.go index 00de0894272..e43c8878fe8 100644 --- a/helper/schema/provider_test.go +++ b/helper/schema/provider_test.go @@ -2420,6 +2420,65 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: nil, }, + "Data source with ValidateRawResourceConfigFuncs returns an error": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + DataSourcesMap: map[string]*Resource{ + "data-foo": { + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + }, + }, + }, + ExpectedErr: fmt.Errorf("data source data-foo cannot contain ValidateRawResourceConfigFuncs"), + }, + "Resource with ValidateRawResourceConfigFuncs returns no errors": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + ResourcesMap: map[string]*Resource{ + "resource-foo": { + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + ExpectedErr: nil, + }, } for name, tc := range cases { From d5a7bd68ffad265abd9557f57800526084c18a82 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 3 Oct 2024 14:18:55 -0400 Subject: [PATCH 22/32] Change `writeOnlyAttributeName` parameter to use `cty.Path` --- helper/validation/write_only.go | 42 +++++++++++++++++++++++----- helper/validation/write_only_test.go | 9 ++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go index 23832d5ae45..78b03ec23cf 100644 --- a/helper/validation/write_only.go +++ b/helper/validation/write_only.go @@ -22,12 +22,37 @@ import ( // For lists: cty.Index(cty.UnknownVal(cty.Number)), // For maps: cty.Index(cty.UnknownVal(cty.String)), // For sets: cty.Index(cty.UnknownVal(cty.Object(nil))), -func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName string) schema.ValidateRawResourceConfigFunc { +func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttribute cty.Path) schema.ValidateRawResourceConfigFunc { return func(ctx context.Context, req schema.ValidateResourceConfigFuncRequest, resp *schema.ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { return } + pathLen := len(writeOnlyAttribute) + + if pathLen == 0 { + return + } + + lastStep := writeOnlyAttribute[pathLen-1] + + // Only attribute steps have a Name field + writeOnlyAttrStep, ok := lastStep.(cty.GetAttrStep) + if !ok { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttribute path", + Detail: "The Terraform Provider unexpectedly provided a path that does not match the current schema. " + + "This can happen if the path does not correctly follow the schema in structure or types. " + + "Please report this to the provider developers. \n\n" + + "The writeOnlyAttribute path provided is invalid. The last step in the path must be a cty.GetAttrStep{}", + AttributePath: writeOnlyAttribute, + }, + } + return + } + var oldAttrs []attribute err := cty.Walk(req.RawConfig, func(path cty.Path, value cty.Value) (bool, error) { @@ -47,22 +72,25 @@ func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName stri for _, attr := range oldAttrs { attrPath := attr.path.Copy() - pathLen := len(attrPath) + pathLen = len(attrPath) if pathLen == 0 { return } - lastStep := attrPath[pathLen-1] + lastStep = attrPath[pathLen-1] // Only attribute steps have a Name field attrStep, ok := lastStep.(cty.GetAttrStep) if !ok { resp.Diagnostics = diag.Diagnostics{ { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "The specified oldAttribute path must point to an attribute.", + Severity: diag.Error, + Summary: "Invalid oldAttribute path", + Detail: "The Terraform Provider unexpectedly provided a path that does not match the current schema. " + + "This can happen if the path does not correctly follow the schema in structure or types. " + + "Please report this to the provider developers. \n\n" + + "The oldAttribute path provided is invalid. The last step in the path must be a cty.GetAttrStep{}", AttributePath: attrPath, }, } @@ -74,7 +102,7 @@ func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName stri Severity: diag.Warning, Summary: "Available Write-Only Attribute Alternative", Detail: fmt.Sprintf("The attribute %s has a WriteOnly version %s available. "+ - "Use the WriteOnly version of the attribute when possible.", attrStep.Name, writeOnlyAttributeName), + "Use the WriteOnly version of the attribute when possible.", attrStep.Name, writeOnlyAttrStep.Name), AttributePath: attr.path, }) } diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go index 8adf6d70f4e..b4dd2445651 100644 --- a/helper/validation/write_only_test.go +++ b/helper/validation/write_only_test.go @@ -45,8 +45,11 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { expectedDiags: diag.Diagnostics{ { Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "The specified oldAttribute path must point to an attribute.", + Summary: "Invalid oldAttribute path", + Detail: "The Terraform Provider unexpectedly provided a path that does not match the current schema. " + + "This can happen if the path does not correctly follow the schema in structure or types. " + + "Please report this to the provider developers. \n\n" + + "The oldAttribute path provided is invalid. The last step in the path must be a cty.GetAttrStep{}", AttributePath: cty.Path{ cty.GetAttrStep{Name: "oldAttribute"}, cty.IndexStep{ @@ -752,7 +755,7 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - f := PreferWriteOnlyAttribute(tc.oldAttributePath, "writeOnlyAttribute") + f := PreferWriteOnlyAttribute(tc.oldAttributePath, cty.GetAttrPath("writeOnlyAttribute")) actual := &schema.ValidateResourceConfigFuncResponse{} f(context.Background(), tc.validateConfigReq, actual) From 5c2e2e2343ac2fb10903fbfb2227eefb6820bb21 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 3 Oct 2024 14:24:21 -0400 Subject: [PATCH 23/32] Simplify validation condition logic --- helper/schema/schema.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 80a3d8ecb57..5076cd5d60d 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -851,7 +851,7 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro return fmt.Errorf("%s: One of optional, required, or computed must be set", k) } - if v.WriteOnly && !(v.Required || v.Optional) { + if v.WriteOnly && v.Required && v.Optional { return fmt.Errorf("%s: WriteOnly must be set with either Required or Optional", k) } From f35082dc431c13748b393f33266d54120f784268 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 13:52:06 -0500 Subject: [PATCH 24/32] Allow write-only attributes to use `ResourceDiff` methods --- helper/schema/resource_diff.go | 5 +- helper/schema/resource_diff_test.go | 136 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/helper/schema/resource_diff.go b/helper/schema/resource_diff.go index 6af9490b9e0..213a839ecf7 100644 --- a/helper/schema/resource_diff.go +++ b/helper/schema/resource_diff.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -614,8 +615,8 @@ func (d *ResourceDiff) checkKey(key, caller string, nested bool) error { if schema == nil { return fmt.Errorf("%s: invalid key: %s", caller, key) } - if !schema.Computed { - return fmt.Errorf("%s only operates on computed keys - %s is not one", caller, key) + if !schema.Computed && !schema.WriteOnly { + return fmt.Errorf("%s only operates on computed or write-only keys - %s is not one", caller, key) } return nil } diff --git a/helper/schema/resource_diff_test.go b/helper/schema/resource_diff_test.go index d9a84676f4b..b6bfb3bd6a3 100644 --- a/helper/schema/resource_diff_test.go +++ b/helper/schema/resource_diff_test.go @@ -634,6 +634,142 @@ func testDiffCases(t *testing.T, computed bool) []resourceDiffTestCase { }(), }, }, + { + Name: "NewComputed should always propagate write-only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "", + }, + ID: "pre-existing", + }, + Config: testConfig(t, map[string]interface{}{}), + Diff: &terraform.InstanceDiff{Attributes: map[string]*terraform.ResourceAttrDiff{}}, + Key: "foo", + NewValue: "", + Expected: &terraform.InstanceDiff{ + Attributes: func() map[string]*terraform.ResourceAttrDiff { + if computed { + return map[string]*terraform.ResourceAttrDiff{ + "foo": { + NewComputed: computed, + }, + } + } + return map[string]*terraform.ResourceAttrDiff{} + }(), + }, + }, + { + Name: "additional diff with primitive - write only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + "one": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "bar", + "one": "", + }, + }, + Config: testConfig(t, map[string]interface{}{ + "foo": "baz", + }), + Diff: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + }, + }, + Key: "one", + NewValue: "four", + Expected: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: func() string { + if computed { + return "" + } + return "four" + }(), + NewComputed: computed, + }, + }, + }, + }, + { + Name: "additional diff with primitive write-only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + "one": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "bar", + "one": "", + }, + }, + Config: testConfig(t, map[string]interface{}{ + "foo": "baz", + "one": "two", + }), + Diff: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: "two", + }, + }, + }, + Key: "one", + NewValue: "three", + Expected: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: func() string { + if computed { + return "" + } + return "three" + }(), + NewComputed: computed, + }, + }, + }, + }, } } From a5d8c2adad4396f5de430c83999cb9c3b1c0de2d Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 14:02:41 -0500 Subject: [PATCH 25/32] run `go mod tidy` --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c2bed6ac1f9..54a6eb151bc 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.23.0 - github.com/hashicorp/terraform-plugin-go v0.25.0 + github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/go-testing-interface v1.14.1 diff --git a/go.sum b/go.sum index 68a66d17058..3879568283d 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= -github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= -github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= +github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 h1:IrFJDqDFuJ7Soy0n/M2oiAn/oH9Cvfi+MMKJh1s2M6I= +github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4/go.mod h1:zoSM9LyEFI4iVkDeKXgwBHV0uTuIIXydtK1fy9J4wBA= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= From a3241f954f94976fd8bb581a0de24fa9196c50fc Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 13:52:06 -0500 Subject: [PATCH 26/32] Allow write-only attributes to use `ResourceDiff` methods --- helper/schema/resource_diff.go | 5 +- helper/schema/resource_diff_test.go | 136 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/helper/schema/resource_diff.go b/helper/schema/resource_diff.go index 6af9490b9e0..213a839ecf7 100644 --- a/helper/schema/resource_diff.go +++ b/helper/schema/resource_diff.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -614,8 +615,8 @@ func (d *ResourceDiff) checkKey(key, caller string, nested bool) error { if schema == nil { return fmt.Errorf("%s: invalid key: %s", caller, key) } - if !schema.Computed { - return fmt.Errorf("%s only operates on computed keys - %s is not one", caller, key) + if !schema.Computed && !schema.WriteOnly { + return fmt.Errorf("%s only operates on computed or write-only keys - %s is not one", caller, key) } return nil } diff --git a/helper/schema/resource_diff_test.go b/helper/schema/resource_diff_test.go index d9a84676f4b..b6bfb3bd6a3 100644 --- a/helper/schema/resource_diff_test.go +++ b/helper/schema/resource_diff_test.go @@ -634,6 +634,142 @@ func testDiffCases(t *testing.T, computed bool) []resourceDiffTestCase { }(), }, }, + { + Name: "NewComputed should always propagate write-only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "", + }, + ID: "pre-existing", + }, + Config: testConfig(t, map[string]interface{}{}), + Diff: &terraform.InstanceDiff{Attributes: map[string]*terraform.ResourceAttrDiff{}}, + Key: "foo", + NewValue: "", + Expected: &terraform.InstanceDiff{ + Attributes: func() map[string]*terraform.ResourceAttrDiff { + if computed { + return map[string]*terraform.ResourceAttrDiff{ + "foo": { + NewComputed: computed, + }, + } + } + return map[string]*terraform.ResourceAttrDiff{} + }(), + }, + }, + { + Name: "additional diff with primitive - write only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + "one": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "bar", + "one": "", + }, + }, + Config: testConfig(t, map[string]interface{}{ + "foo": "baz", + }), + Diff: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + }, + }, + Key: "one", + NewValue: "four", + Expected: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: func() string { + if computed { + return "" + } + return "four" + }(), + NewComputed: computed, + }, + }, + }, + }, + { + Name: "additional diff with primitive write-only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + "one": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "bar", + "one": "", + }, + }, + Config: testConfig(t, map[string]interface{}{ + "foo": "baz", + "one": "two", + }), + Diff: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: "two", + }, + }, + }, + Key: "one", + NewValue: "three", + Expected: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: func() string { + if computed { + return "" + } + return "three" + }(), + NewComputed: computed, + }, + }, + }, + }, } } From 3f32220ede4409c3770cc5bcec434bcf30b67fe6 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 14:40:04 -0500 Subject: [PATCH 27/32] update `terraform-plugin-go` dependency --- go.mod | 4 ++-- go.sum | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 54a6eb151bc..3c6f5caafa3 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.23.0 - github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 + github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/go-testing-interface v1.14.1 @@ -57,5 +57,5 @@ require ( google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.35.2 // indirect ) diff --git a/go.sum b/go.sum index 3879568283d..3dafba24a45 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2 github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 h1:IrFJDqDFuJ7Soy0n/M2oiAn/oH9Cvfi+MMKJh1s2M6I= github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4/go.mod h1:zoSM9LyEFI4iVkDeKXgwBHV0uTuIIXydtK1fy9J4wBA= +github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0 h1:92SnqDgZi6TgHrJlbP5UicGWgoZO+QQUrZyzwW2Ztqs= +github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0/go.mod h1:f8P2pHGkZrtdKLpCI2qIvrewUY+c4nTvtayqjJR9IcY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= @@ -203,6 +205,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 8829fa669ed6ca00062e0e7742c10a5749de9f4d Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 13:52:06 -0500 Subject: [PATCH 28/32] Allow write-only attributes to use `ResourceDiff` methods --- helper/schema/resource_diff.go | 5 +- helper/schema/resource_diff_test.go | 136 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/helper/schema/resource_diff.go b/helper/schema/resource_diff.go index 6af9490b9e0..213a839ecf7 100644 --- a/helper/schema/resource_diff.go +++ b/helper/schema/resource_diff.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -614,8 +615,8 @@ func (d *ResourceDiff) checkKey(key, caller string, nested bool) error { if schema == nil { return fmt.Errorf("%s: invalid key: %s", caller, key) } - if !schema.Computed { - return fmt.Errorf("%s only operates on computed keys - %s is not one", caller, key) + if !schema.Computed && !schema.WriteOnly { + return fmt.Errorf("%s only operates on computed or write-only keys - %s is not one", caller, key) } return nil } diff --git a/helper/schema/resource_diff_test.go b/helper/schema/resource_diff_test.go index d9a84676f4b..b6bfb3bd6a3 100644 --- a/helper/schema/resource_diff_test.go +++ b/helper/schema/resource_diff_test.go @@ -634,6 +634,142 @@ func testDiffCases(t *testing.T, computed bool) []resourceDiffTestCase { }(), }, }, + { + Name: "NewComputed should always propagate write-only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "", + }, + ID: "pre-existing", + }, + Config: testConfig(t, map[string]interface{}{}), + Diff: &terraform.InstanceDiff{Attributes: map[string]*terraform.ResourceAttrDiff{}}, + Key: "foo", + NewValue: "", + Expected: &terraform.InstanceDiff{ + Attributes: func() map[string]*terraform.ResourceAttrDiff { + if computed { + return map[string]*terraform.ResourceAttrDiff{ + "foo": { + NewComputed: computed, + }, + } + } + return map[string]*terraform.ResourceAttrDiff{} + }(), + }, + }, + { + Name: "additional diff with primitive - write only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + "one": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "bar", + "one": "", + }, + }, + Config: testConfig(t, map[string]interface{}{ + "foo": "baz", + }), + Diff: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + }, + }, + Key: "one", + NewValue: "four", + Expected: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: func() string { + if computed { + return "" + } + return "four" + }(), + NewComputed: computed, + }, + }, + }, + }, + { + Name: "additional diff with primitive write-only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + "one": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "bar", + "one": "", + }, + }, + Config: testConfig(t, map[string]interface{}{ + "foo": "baz", + "one": "two", + }), + Diff: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: "two", + }, + }, + }, + Key: "one", + NewValue: "three", + Expected: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: func() string { + if computed { + return "" + } + return "three" + }(), + NewComputed: computed, + }, + }, + }, + }, } } From 15e2d7db0855394bfbfe727c3a793883a0e13bf3 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 18:13:35 -0500 Subject: [PATCH 29/32] Temporarily comment out write-only client capability validation for testing --- go.sum | 5 +---- helper/schema/grpc_provider.go | 12 ++++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/go.sum b/go.sum index 3dafba24a45..f58e73d0319 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= -github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 h1:IrFJDqDFuJ7Soy0n/M2oiAn/oH9Cvfi+MMKJh1s2M6I= -github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4/go.mod h1:zoSM9LyEFI4iVkDeKXgwBHV0uTuIIXydtK1fy9J4wBA= github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0 h1:92SnqDgZi6TgHrJlbP5UicGWgoZO+QQUrZyzwW2Ztqs= github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0/go.mod h1:f8P2pHGkZrtdKLpCI2qIvrewUY+c4nTvtayqjJR9IcY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= @@ -203,8 +201,7 @@ google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 2b328f1ad27..f2ed3c5ce20 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -283,9 +283,9 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) return resp, nil } - if req.ClientCapabilities == nil || !req.ClientCapabilities.WriteOnlyAttributesAllowed { - resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock, cty.Path{})) - } + //if req.ClientCapabilities == nil || !req.ClientCapabilities.WriteOnlyAttributesAllowed { + // resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock, cty.Path{})) + //} r := s.provider.ResourcesMap[req.TypeName] @@ -1220,9 +1220,9 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro newStateVal = copyTimeoutValues(newStateVal, plannedStateVal) - if req.ClientCapabilities != nil && req.ClientCapabilities.WriteOnlyAttributesAllowed { - newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) - } + //if req.ClientCapabilities != nil && req.ClientCapabilities.WriteOnlyAttributesAllowed { + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + //} newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil { From 28a57689ab6174a4a6bace1d459103f55faf74dc Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 19:02:38 -0500 Subject: [PATCH 30/32] Add write-only support to `ProtoToConfigSchema()` --- internal/plugin/convert/schema.go | 2 ++ internal/plugin/convert/schema_test.go | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/internal/plugin/convert/schema.go b/internal/plugin/convert/schema.go index e2b4e431ce9..a02aaec0078 100644 --- a/internal/plugin/convert/schema.go +++ b/internal/plugin/convert/schema.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" ) @@ -151,6 +152,7 @@ func ConfigSchemaToProto(ctx context.Context, b *configschema.Block) *tfprotov5. Required: a.Required, Sensitive: a.Sensitive, Deprecated: a.Deprecated, + WriteOnly: a.WriteOnly, } var err error diff --git a/internal/plugin/convert/schema_test.go b/internal/plugin/convert/schema_test.go index cf8b17aded6..993fb476948 100644 --- a/internal/plugin/convert/schema_test.go +++ b/internal/plugin/convert/schema_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" ) @@ -232,6 +233,12 @@ func TestConvertProtoSchemaBlocks(t *testing.T) { Type: tftypes.Number, Required: true, }, + { + Name: "write-only", + Type: tftypes.String, + WriteOnly: true, + Optional: true, + }, }, }, &configschema.Block{ @@ -253,6 +260,11 @@ func TestConvertProtoSchemaBlocks(t *testing.T) { Type: cty.Number, Required: true, }, + "write-only": { + Type: cty.String, + WriteOnly: true, + Optional: true, + }, }, }, }, From c198c0e0484b29ea2a7cad1bc73c54c161f846d3 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 13:52:06 -0500 Subject: [PATCH 31/32] Allow write-only attributes to use `ResourceDiff` methods --- helper/schema/resource_diff.go | 5 +- helper/schema/resource_diff_test.go | 136 ++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+), 2 deletions(-) diff --git a/helper/schema/resource_diff.go b/helper/schema/resource_diff.go index 6af9490b9e0..213a839ecf7 100644 --- a/helper/schema/resource_diff.go +++ b/helper/schema/resource_diff.go @@ -11,6 +11,7 @@ import ( "sync" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -614,8 +615,8 @@ func (d *ResourceDiff) checkKey(key, caller string, nested bool) error { if schema == nil { return fmt.Errorf("%s: invalid key: %s", caller, key) } - if !schema.Computed { - return fmt.Errorf("%s only operates on computed keys - %s is not one", caller, key) + if !schema.Computed && !schema.WriteOnly { + return fmt.Errorf("%s only operates on computed or write-only keys - %s is not one", caller, key) } return nil } diff --git a/helper/schema/resource_diff_test.go b/helper/schema/resource_diff_test.go index d9a84676f4b..b6bfb3bd6a3 100644 --- a/helper/schema/resource_diff_test.go +++ b/helper/schema/resource_diff_test.go @@ -634,6 +634,142 @@ func testDiffCases(t *testing.T, computed bool) []resourceDiffTestCase { }(), }, }, + { + Name: "NewComputed should always propagate write-only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "", + }, + ID: "pre-existing", + }, + Config: testConfig(t, map[string]interface{}{}), + Diff: &terraform.InstanceDiff{Attributes: map[string]*terraform.ResourceAttrDiff{}}, + Key: "foo", + NewValue: "", + Expected: &terraform.InstanceDiff{ + Attributes: func() map[string]*terraform.ResourceAttrDiff { + if computed { + return map[string]*terraform.ResourceAttrDiff{ + "foo": { + NewComputed: computed, + }, + } + } + return map[string]*terraform.ResourceAttrDiff{} + }(), + }, + }, + { + Name: "additional diff with primitive - write only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + "one": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "bar", + "one": "", + }, + }, + Config: testConfig(t, map[string]interface{}{ + "foo": "baz", + }), + Diff: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + }, + }, + Key: "one", + NewValue: "four", + Expected: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: func() string { + if computed { + return "" + } + return "four" + }(), + NewComputed: computed, + }, + }, + }, + }, + { + Name: "additional diff with primitive write-only", + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + "one": { + Type: TypeString, + WriteOnly: true, + }, + }, + State: &terraform.InstanceState{ + Attributes: map[string]string{ + "foo": "bar", + "one": "", + }, + }, + Config: testConfig(t, map[string]interface{}{ + "foo": "baz", + "one": "two", + }), + Diff: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: "two", + }, + }, + }, + Key: "one", + NewValue: "three", + Expected: &terraform.InstanceDiff{ + Attributes: map[string]*terraform.ResourceAttrDiff{ + "foo": { + Old: "bar", + New: "baz", + }, + "one": { + Old: "", + New: func() string { + if computed { + return "" + } + return "three" + }(), + NewComputed: computed, + }, + }, + }, + }, } } From 5ca30832ee8d1e20538fcbee252963ab0f8e139e Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 18:13:35 -0500 Subject: [PATCH 32/32] Temporarily comment out write-only client capability validation for testing --- go.sum | 5 +---- helper/schema/grpc_provider.go | 12 ++++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/go.sum b/go.sum index 3dafba24a45..f58e73d0319 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,6 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= -github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 h1:IrFJDqDFuJ7Soy0n/M2oiAn/oH9Cvfi+MMKJh1s2M6I= -github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4/go.mod h1:zoSM9LyEFI4iVkDeKXgwBHV0uTuIIXydtK1fy9J4wBA= github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0 h1:92SnqDgZi6TgHrJlbP5UicGWgoZO+QQUrZyzwW2Ztqs= github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0/go.mod h1:f8P2pHGkZrtdKLpCI2qIvrewUY+c4nTvtayqjJR9IcY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= @@ -203,8 +201,7 @@ google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= -google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 2b328f1ad27..f2ed3c5ce20 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -283,9 +283,9 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) return resp, nil } - if req.ClientCapabilities == nil || !req.ClientCapabilities.WriteOnlyAttributesAllowed { - resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock, cty.Path{})) - } + //if req.ClientCapabilities == nil || !req.ClientCapabilities.WriteOnlyAttributesAllowed { + // resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock, cty.Path{})) + //} r := s.provider.ResourcesMap[req.TypeName] @@ -1220,9 +1220,9 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro newStateVal = copyTimeoutValues(newStateVal, plannedStateVal) - if req.ClientCapabilities != nil && req.ClientCapabilities.WriteOnlyAttributesAllowed { - newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) - } + //if req.ClientCapabilities != nil && req.ClientCapabilities.WriteOnlyAttributesAllowed { + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + //} newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil {