From 6e8402b5718c52fb2e646a204c5f08927edf79b0 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 14:13:06 -0400 Subject: [PATCH 01/40] 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 4490809719..fc88b9544b 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 c56ff86b7f..07f42a50a9 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/40] 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 736af218da..3f12fd3b6d 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 b8362f15ec..76fcfa8b94 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 176288b0cd..cfb85020b2 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 1c9b1f2198..6099da29d1 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 c445b4ba55..a45c5cc241 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/40] 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 a75ae2fc28..498d0f5542 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 dcab8acd71..00de089427 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 5c9fd629ba..447338e6d5 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/40] 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 ec5d74301a..4f7f583a6a 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 7dacdd6ea5..f9af8c9a8d 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/40] 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 cfb85020b2..9cffc321bb 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 6099da29d1..ebe2bcc080 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/40] 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 07f42a50a9..a7a020da74 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 9cffc321bb..8dde046902 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/40] 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 4f7f583a6a..2f82c6c75b 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 f9af8c9a8d..3ee32217e4 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/40] 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 8dde046902..fe29e42e6c 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 0000000000..b729c81a2f --- /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 0000000000..69e6edb898 --- /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/40] 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 0000000000..b17449a3d5 --- /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 0000000000..aabb2dc281 --- /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 b729c81a2f..1fa9636814 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 69e6edb898..d641c490ea 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/40] 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 2f82c6c75b..34c67bb437 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 3ee32217e4..1c00608437 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 1c944c9b48..dd52d1ca77 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 fe29e42e6c..8dde046902 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 d641c490ea..8adf6d70f4 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/40] 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 34c67bb437..8bc0190e64 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 1c00608437..6e357770b9 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/40] 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 8bc0190e64..436bfafe9f 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 8dde046902..ce58deef24 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/40] 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 436bfafe9f..3f4041becb 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/40] 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 6e357770b9..c041a57b67 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/40] 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 3f4041becb..13fd33b9d3 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 c041a57b67..f7b04a4222 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 0000000000..db62f33f4d --- /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 0000000000..8620edc541 --- /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/40] 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 db62f33f4d..cabf94b6d8 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 8620edc541..7b8e9dbb41 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/40] 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 13fd33b9d3..51b83732d3 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 cabf94b6d8..7a375e0b64 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 7b8e9dbb41..e0893805ae 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/40] 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 51b83732d3..5abb03d435 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 f7b04a4222..e620c97073 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 7a375e0b64..a87db12cd8 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 e0893805ae..03889af615 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/40] 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 5abb03d435..a49e5a4165 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 e620c97073..954f23aef7 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 dd52d1ca77..0d46c3aac8 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 b17449a3d5..b8707330d0 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 aabb2dc281..b85b837ed6 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 1fa9636814..23832d5ae4 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/40] 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 ce58deef24..80a3d8ecb5 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 a45c5cc241..bf29acab63 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/40] 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 498d0f5542..d60e2d3764 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 00de089427..e43c8878fe 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/40] 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 23832d5ae4..78b03ec23c 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 8adf6d70f4..b4dd244565 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/40] 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 80a3d8ecb5..5076cd5d60 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 a5d8c2adad4396f5de430c83999cb9c3b1c0de2d Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 14:02:41 -0500 Subject: [PATCH 24/40] 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 c2bed6ac1f..54a6eb151b 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 68a66d1705..3879568283 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 3f32220ede4409c3770cc5bcec434bcf30b67fe6 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 14:40:04 -0500 Subject: [PATCH 25/40] 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 54a6eb151b..3c6f5caafa 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 3879568283..3dafba24a4 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 28a57689ab6174a4a6bace1d459103f55faf74dc Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 19:02:38 -0500 Subject: [PATCH 26/40] 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 e2b4e431ce..a02aaec007 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 cf8b17aded..993fb47694 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 2aec830304b5840f2ea183fd0949d0154b36a776 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 3 Dec 2024 16:36:54 -0500 Subject: [PATCH 27/40] Nullify write-only attributes during Plan and Apply regardless of client capability --- helper/schema/grpc_provider.go | 7 ++++--- helper/schema/schema.go | 3 +++ 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 2b328f1ad2..89e3990eea 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -973,6 +973,9 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot plannedStateVal = SetUnknowns(plannedStateVal, schemaBlock) } + // Set any write-only attribute values to null + plannedStateVal = setWriteOnlyNullValues(plannedStateVal, schemaBlock) + plannedMP, err := msgpack.Marshal(plannedStateVal, schemaBlock.ImpliedType()) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) @@ -1220,9 +1223,7 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro newStateVal = copyTimeoutValues(newStateVal, plannedStateVal) - if req.ClientCapabilities != nil && req.ClientCapabilities.WriteOnlyAttributesAllowed { - newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) - } + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil { diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 1737ca775a..90eefe9cc2 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -2400,6 +2400,9 @@ func (m schemaMap) hasWriteOnly() bool { return true } + // Test the edge case where elements in a collection are set to writeOnly. + // Technically, this is an invalid schema as collections cannot have write-only + // attributes. However, this method is not concerned with the validity of the schema. isNestedWriteOnly := schemaMap(map[string]*Schema{"nested": t}).hasWriteOnly() if isNestedWriteOnly { return true From 1479d620f38eccb20aac20d939bfe9f9d5cd84fb Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Dec 2024 16:23:56 -0500 Subject: [PATCH 28/40] Introduce `(*ResourceData).GetRawWriteOnly()` and `(*ResourceData).GetWriteOnly()` for retrieving write-only values during apply --- helper/schema/grpc_provider.go | 6 + helper/schema/grpc_provider_test.go | 315 ++++++++++++++++++++++ helper/schema/resource_data.go | 47 ++++ helper/schema/resource_data_test.go | 79 ++++++ helper/schema/write_only.go | 99 +++++++ helper/schema/write_only_test.go | 387 ++++++++++++++++++++++++++++ terraform/diff.go | 5 + 7 files changed, 938 insertions(+) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 89e3990eea..914bc947d1 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -1191,6 +1191,12 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro priorState.ProviderMeta = providerSchemaVal } + // This is a hack to pass write-only values to instanceDiff, + // for (*ResourceData).GetRawWriteOnly() and (*ResourceData).GetWriteOnly(). + // Ideally, this should be set in DiffFromValues() but since it is a public + // function, we cannot change its signature. + diff.RawWriteOnly = createRawWriteOnly(configVal, schemaBlock) + newInstanceState, diags := res.Apply(ctx, priorState, diff, s.provider.Meta()) resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, diags) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 68ec219825..bd81772d76 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -5094,6 +5094,88 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, + "create-writeonly-plan-modification": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { + val := d.Get("foo") + if val != "bar" { + t.Fatalf("Incorrect write-only value") + } + + return nil + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + 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.StringVal("bar"), + }), + ), + }, + 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.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &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), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, } for name, testCase := range testCases { @@ -5528,6 +5610,239 @@ func TestApplyResourceChange_bigint(t *testing.T) { } } +func TestApplyResourceChange_writeOnly(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + TestResource *Resource + ExpectedUnsafeLegacyTypeSystem bool + }{ + "Create": { + TestResource: &Resource{ + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + Create: func(rd *ResourceData, _ interface{}) error { + rd.SetId("baz") + writeOnlyVal, err := rd.GetWriteOnly(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %s", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "CreateContext": { + TestResource: &Resource{ + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + writeOnlyVal, err := rd.GetWriteOnly(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %s", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "CreateWithoutTimeout": { + TestResource: &Resource{ + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + CreateWithoutTimeout: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + writeOnlyVal, err := rd.GetWriteOnly(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %s", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "Create_cty": { + TestResource: &Resource{ + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + CreateWithoutTimeout: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + if rd.GetRawWriteOnly().IsNull() { + return diag.FromErr(errors.New("null raw writeOnly val")) + } + if rd.GetRawWriteOnly().GetAttr("write_only_bar").Type() != cty.String { + return diag.FromErr(errors.New("write_only_bar is not of the expected type string")) + } + writeOnlyVal := rd.GetRawWriteOnly().GetAttr("write_only_bar").AsString() + if writeOnlyVal != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "CreateContext_SchemaFunc": { + TestResource: &Resource{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + Computed: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + } + }, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + writeOnlyVal, err := rd.GetWriteOnly(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %s", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + server := NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": testCase.TestResource, + }, + }) + + schema := testCase.TestResource.CoreConfigSchema() + priorState, err := msgpack.Marshal(cty.NullVal(schema.ImpliedType()), schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + // A proposed state with only the ID unknown will produce a nil diff, and + // should return the proposed state value. + plannedVal, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + })) + if err != nil { + t.Fatal(err) + } + plannedState, err := msgpack.Marshal(plannedVal, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + config, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "write_only_bar": cty.StringVal("bar"), + })) + if err != nil { + t.Fatal(err) + } + configBytes, err := msgpack.Marshal(config, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + testReq := &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: priorState, + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: plannedState, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: configBytes, + }, + } + + resp, err := server.ApplyResourceChange(context.Background(), testReq) + if err != nil { + t.Fatal(err) + } + + newStateVal, err := msgpack.Unmarshal(resp.NewState.MsgPack, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + id := newStateVal.GetAttr("id").AsString() + if id != "baz" { + t.Fatalf("incorrect final state: %#v\n", newStateVal) + } + + //nolint:staticcheck // explicitly for this SDK + if testCase.ExpectedUnsafeLegacyTypeSystem != resp.UnsafeToUseLegacyTypeSystem { + //nolint:staticcheck // explicitly for this SDK + t.Fatalf("expected UnsafeLegacyTypeSystem %t, got: %t", testCase.ExpectedUnsafeLegacyTypeSystem, resp.UnsafeToUseLegacyTypeSystem) + } + }) + } +} + func TestImportResourceState(t *testing.T) { t.Parallel() diff --git a/helper/schema/resource_data.go b/helper/schema/resource_data.go index 56306a190f..714a823e81 100644 --- a/helper/schema/resource_data.go +++ b/helper/schema/resource_data.go @@ -4,6 +4,8 @@ package schema import ( + "errors" + "fmt" "log" "reflect" "strings" @@ -13,6 +15,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/go-cty/cty/gocty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -444,6 +447,35 @@ func (d *ResourceData) Timeout(key string) time.Duration { return defaultTimeout } +// GetWriteOnly returns a cty.Value for a given path to a write-only attribute. +// An error will be thrown if the path does not exist in the rawWriteOnly cty.Value +// or the rawWriteOnly value is null. If an error is thrown, the return cty.Value will +// be a null value of an empty cty object. +func (d *ResourceData) GetWriteOnly(writeOnlyPath cty.Path) (cty.Value, error) { + rawWriteOnly := d.GetRawWriteOnly() + writeOnlyVal := cty.NullVal(cty.EmptyObject) + + if rawWriteOnly.IsNull() { + return writeOnlyVal, errors.New("no write-only values exist in the config") + } + err := cty.Walk(rawWriteOnly, func(path cty.Path, value cty.Value) (bool, error) { + if path.Equals(writeOnlyPath) { + writeOnlyVal = value + return false, nil + } + return true, nil + }) + if err != nil { + return writeOnlyVal, fmt.Errorf("encountered error while retrieving write-only value %s", err) + } + + if writeOnlyVal.IsNull() { + return writeOnlyVal, fmt.Errorf("no write-only value found for given path %v", writeOnlyPath) + } + + return writeOnlyVal, nil +} + func (d *ResourceData) init() { // Initialize the field that will store our new state var copyState terraform.InstanceState @@ -637,3 +669,18 @@ func (d *ResourceData) GetRawPlan() cty.Value { } return cty.NullVal(schemaMap(d.schema).CoreConfigSchema().ImpliedType()) } + +// GetRawWriteOnly returns a cty.Value containing all write-only attributes +// values sent in the config. Since the cty.Value contains only write-only values, +// it is NOT expected to match the resource's type. +// If no value was sent, or if a null value was sent, the value will be a null +// value of an empty cty object. +// +// GetRawWriteOnly is considered experimental and advanced functionality, and +// familiarity with the Terraform protocol is suggested when using it. +func (d *ResourceData) GetRawWriteOnly() cty.Value { + if d.diff != nil && !d.diff.RawWriteOnly.IsNull() { + return d.diff.RawWriteOnly + } + return cty.NullVal(cty.EmptyObject) +} diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index c9f71081d3..cc3776abc9 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -756,6 +758,83 @@ func TestResourceDataGet(t *testing.T) { } } +func TestResourceDataWriteOnlyGet(t *testing.T) { + cases := map[string]struct { + RawWriteOnly cty.Value + Path cty.Path + Value cty.Value + ExpectedErr error + }{ + "null RawWriteOnly returns error": { + RawWriteOnly: cty.NullVal(cty.EmptyObject), + Path: cty.GetAttrPath("invalid_root_path"), + Value: cty.NullVal(cty.EmptyObject), + ExpectedErr: fmt.Errorf("no write-only values exist in the config"), + }, + "invalid path returns error": { + RawWriteOnly: cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + Path: cty.GetAttrPath("invalid_root_path"), + Value: cty.NullVal(cty.EmptyObject), + ExpectedErr: fmt.Errorf("no write-only value found for given path [{{} invalid_root_path}]"), + }, + "root level attribute": { + RawWriteOnly: cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + Path: cty.GetAttrPath("writeOnlyAttribute"), + Value: cty.NumberIntVal(42), + ExpectedErr: nil, + }, + "list nested block attribute - get attribute value": { + RawWriteOnly: cty.ObjectVal(map[string]cty.Value{ + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.StringVal("valueA"), + }), + cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.StringVal("valueB"), + }), + }), + }), + Path: cty.GetAttrPath("list_nested_block").IndexInt(1).GetAttr("writeOnlyAttribute"), + Value: cty.StringVal("valueB"), + ExpectedErr: nil, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + diff := &terraform.InstanceDiff{ + RawWriteOnly: tc.RawWriteOnly, + } + d := &ResourceData{ + diff: diff, + } + + v, err := d.GetWriteOnly(tc.Path) + if err == nil && tc.ExpectedErr != nil { + t.Fatalf("expected error: %s, but encountered no error", tc.ExpectedErr.Error()) + } + + if err != nil && tc.ExpectedErr == nil { + t.Fatalf("encountered unexepected error: %s", err.Error()) + } + + if err != nil && tc.ExpectedErr != nil { + if err.Error() != tc.ExpectedErr.Error() { + t.Errorf("expected error: %s not equal to encountered error: %s", tc.ExpectedErr.Error(), err.Error()) + } + } + + if !reflect.DeepEqual(v, tc.Value) { + t.Errorf("Bad: %s\n\n%#v\n\nExpected: %#v", tn, v, tc.Value) + } + }) + } +} + func TestResourceDataGetChange(t *testing.T) { cases := []struct { Schema map[string]*Schema diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index a87db12cd8..37d73de8fe 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -210,6 +210,105 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs return diags } +// createRawWriteOnly takes a cty.Value of the config and the schema to create a new +// cty.Value with only write-only attributes values. +// MAINTAINER NOTE: The resultant cty.Value will NOT match the cty.Type of the input schema, as +// this function was only meant to create a "read-only" cty.Value for shimming. +func createRawWriteOnly(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 { + 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 + nestedVal := createRawWriteOnly(blockVal, &blockS.Block) + if nestedVal.LengthInt() > 0 { + newVals[name] = nestedVal + } + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + newListVals := make([]cty.Value, 0, len(listVals)) + + for _, v := range listVals { + nestedVal := createRawWriteOnly(v, &blockS.Block) + if nestedVal.LengthInt() > 0 { + newListVals = append(newListVals, createRawWriteOnly(v, &blockS.Block)) + } + } + + switch { + case blockValType.IsSetType(): + if len(newListVals) > 0 { + newVals[name] = cty.SetVal(newListVals) + } + case blockValType.IsListType(): + if len(newListVals) > 0 { + 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 { + nestedVal := createRawWriteOnly(v, &blockS.Block) + if nestedVal.LengthInt() > 0 { + newMapVals[k] = nestedVal + } + } + + switch { + case blockValType.IsMapType(): + if len(newMapVals) > 0 { + newVals[name] = cty.MapVal(newMapVals) + } + //TODO: write a test that exercises this code. + 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 create write-only cty value for nested block %q:%#v", name, blockValType)) + } + } + + 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, path cty.Path) diag.Diagnostics { diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 03889af615..3a8884b39f 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -10,6 +10,393 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" ) +func Test_createRawWriteOnly(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected cty.Value + }{ + "Empty object returns empty object": { + &configschema.Block{}, + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }, + "Top level attributes and blocks": { + &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, + }, + }, + }, + }, + "nested_block_without_write_only": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "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"), + }), + "nested_block_without_write_only": cty.ObjectVal(map[string]cty.Value{ + "required_block_attribute": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "write_only_attribute": cty.StringVal("blep"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("blep"), + }), + }), + }, + "Set nested block: write only Nested Attribute": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_attribute": { + Type: cty.String, + Required: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "set_block": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_block_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "block_attribute": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + "set_block_without_write_only": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "block_attribute": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + 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.StringVal("beep"), + "block_attribute": cty.StringVal("blep"), + }), + }), + "set_block_without_write_only": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "block_attribute": cty.StringVal("blep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "set_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + }), + }), + }), + }, + "Nested single block: write only nested attribute": { + &configschema.Block{ + 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, + }, + "optional_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + "nested_block_without_write_only": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "optional_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("boop"), + "optional_attribute": cty.StringVal("boop"), + }), + "nested_block_without_write_only": cty.ObjectVal(map[string]cty.Value{ + "optional_attribute": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("boop"), + }), + }), + }, + "Map nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "map_block": { + Nesting: configschema.NestingMap, + 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, + }, + }, + }, + }, + "map_block_without_write_only": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "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"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.NullVal(cty.String), + }), + }), + "map_block_without_write_only": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("blep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + }), + }), + }), + }, + "List nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "list_block": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_block_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + "list_block_without_write_only": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "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{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + }), + }), + "list_block_without_write_only": 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"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + }), + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + }), + }), + }), + }, + "Set nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "set_block": { + Nesting: configschema.NestingSet, + 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, + }, + }, + }, + }, + "set_block_without_write_only": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "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), + }), + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("boop"), + }), + }), + "set_block_without_write_only": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("boop"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "set_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("blep"), + }), + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + }), + }), + }), + }, + } { + t.Run(n, func(t *testing.T) { + got := createRawWriteOnly(tc.Val, tc.Schema) + + if !got.RawEquals(tc.Expected) { + t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) + } + }) + } +} + func Test_setWriteOnlyNullValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block diff --git a/terraform/diff.go b/terraform/diff.go index 3b4179b4b3..867089b9fb 100644 --- a/terraform/diff.go +++ b/terraform/diff.go @@ -46,6 +46,11 @@ type InstanceDiff struct { RawState cty.Value RawPlan cty.Value + // RawWriteOnly is the raw cty value of the write-only attribute values + // set in the final config. The cty.Type of this value is NOT expected to match + // the resource schema or the RawConfig / RawState / RawPlan. + RawWriteOnly cty.Value + // Meta is a simple K/V map that is stored in a diff and persisted to // plans but otherwise is completely ignored by Terraform core. It is // meant to be used for additional data a resource may want to pass through. From 18894a88020b8ff83a24be2a1980674bf28dc98c Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Dec 2024 17:15:54 -0500 Subject: [PATCH 29/40] Revert "Introduce `(*ResourceData).GetRawWriteOnly()` and `(*ResourceData).GetWriteOnly()` for retrieving write-only values during apply" This reverts commit 1479d620f38eccb20aac20d939bfe9f9d5cd84fb. --- helper/schema/grpc_provider.go | 6 - helper/schema/grpc_provider_test.go | 315 ---------------------- helper/schema/resource_data.go | 47 ---- helper/schema/resource_data_test.go | 79 ------ helper/schema/write_only.go | 99 ------- helper/schema/write_only_test.go | 387 ---------------------------- terraform/diff.go | 5 - 7 files changed, 938 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 914bc947d1..89e3990eea 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -1191,12 +1191,6 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro priorState.ProviderMeta = providerSchemaVal } - // This is a hack to pass write-only values to instanceDiff, - // for (*ResourceData).GetRawWriteOnly() and (*ResourceData).GetWriteOnly(). - // Ideally, this should be set in DiffFromValues() but since it is a public - // function, we cannot change its signature. - diff.RawWriteOnly = createRawWriteOnly(configVal, schemaBlock) - newInstanceState, diags := res.Apply(ctx, priorState, diff, s.provider.Meta()) resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, diags) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index bd81772d76..68ec219825 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -5094,88 +5094,6 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, - "create-writeonly-plan-modification": { - server: NewGRPCProviderServer(&Provider{ - ResourcesMap: map[string]*Resource{ - "test": { - SchemaVersion: 4, - CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { - val := d.Get("foo") - if val != "bar" { - t.Fatalf("Incorrect write-only value") - } - - return nil - }, - Schema: map[string]*Schema{ - "foo": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }), - req: &tfprotov5.PlanResourceChangeRequest{ - TypeName: "test", - 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.StringVal("bar"), - }), - ), - }, - 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.StringVal("bar"), - }), - ), - }, - }, - expected: &tfprotov5.PlanResourceChangeResponse{ - PlannedState: &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), - }), - ), - }, - PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), - RequiresReplace: []*tftypes.AttributePath{ - tftypes.NewAttributePath().WithAttributeName("id"), - }, - UnsafeToUseLegacyTypeSystem: true, - }, - }, } for name, testCase := range testCases { @@ -5610,239 +5528,6 @@ func TestApplyResourceChange_bigint(t *testing.T) { } } -func TestApplyResourceChange_writeOnly(t *testing.T) { - t.Parallel() - - testCases := map[string]struct { - TestResource *Resource - ExpectedUnsafeLegacyTypeSystem bool - }{ - "Create": { - TestResource: &Resource{ - SchemaVersion: 4, - Schema: map[string]*Schema{ - "foo": { - Type: TypeInt, - Optional: true, - }, - "write_only_bar": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, - }, - Create: func(rd *ResourceData, _ interface{}) error { - rd.SetId("baz") - writeOnlyVal, err := rd.GetWriteOnly(cty.GetAttrPath("write_only_bar")) - if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %s", err) - } - if writeOnlyVal.AsString() != "bar" { - t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) - } - return nil - }, - }, - ExpectedUnsafeLegacyTypeSystem: true, - }, - "CreateContext": { - TestResource: &Resource{ - SchemaVersion: 4, - Schema: map[string]*Schema{ - "foo": { - Type: TypeInt, - Optional: true, - }, - "write_only_bar": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, - }, - CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { - rd.SetId("baz") - writeOnlyVal, err := rd.GetWriteOnly(cty.GetAttrPath("write_only_bar")) - if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %s", err) - } - if writeOnlyVal.AsString() != "bar" { - t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) - } - return nil - }, - }, - ExpectedUnsafeLegacyTypeSystem: true, - }, - "CreateWithoutTimeout": { - TestResource: &Resource{ - SchemaVersion: 4, - Schema: map[string]*Schema{ - "foo": { - Type: TypeInt, - Optional: true, - }, - "write_only_bar": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, - }, - CreateWithoutTimeout: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { - rd.SetId("baz") - writeOnlyVal, err := rd.GetWriteOnly(cty.GetAttrPath("write_only_bar")) - if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %s", err) - } - if writeOnlyVal.AsString() != "bar" { - t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) - } - return nil - }, - }, - ExpectedUnsafeLegacyTypeSystem: true, - }, - "Create_cty": { - TestResource: &Resource{ - SchemaVersion: 4, - Schema: map[string]*Schema{ - "foo": { - Type: TypeInt, - Optional: true, - }, - "write_only_bar": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, - }, - CreateWithoutTimeout: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { - rd.SetId("baz") - if rd.GetRawWriteOnly().IsNull() { - return diag.FromErr(errors.New("null raw writeOnly val")) - } - if rd.GetRawWriteOnly().GetAttr("write_only_bar").Type() != cty.String { - return diag.FromErr(errors.New("write_only_bar is not of the expected type string")) - } - writeOnlyVal := rd.GetRawWriteOnly().GetAttr("write_only_bar").AsString() - if writeOnlyVal != "bar" { - t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) - } - return nil - }, - }, - ExpectedUnsafeLegacyTypeSystem: true, - }, - "CreateContext_SchemaFunc": { - TestResource: &Resource{ - SchemaFunc: func() map[string]*Schema { - return map[string]*Schema{ - "id": { - Type: TypeString, - Computed: true, - }, - "write_only_bar": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, - } - }, - CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { - rd.SetId("baz") - writeOnlyVal, err := rd.GetWriteOnly(cty.GetAttrPath("write_only_bar")) - if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %s", err) - } - if writeOnlyVal.AsString() != "bar" { - t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) - } - return nil - }, - }, - ExpectedUnsafeLegacyTypeSystem: true, - }, - } - - for name, testCase := range testCases { - name, testCase := name, testCase - - t.Run(name, func(t *testing.T) { - t.Parallel() - - server := NewGRPCProviderServer(&Provider{ - ResourcesMap: map[string]*Resource{ - "test": testCase.TestResource, - }, - }) - - schema := testCase.TestResource.CoreConfigSchema() - priorState, err := msgpack.Marshal(cty.NullVal(schema.ImpliedType()), schema.ImpliedType()) - if err != nil { - t.Fatal(err) - } - - // A proposed state with only the ID unknown will produce a nil diff, and - // should return the proposed state value. - plannedVal, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ - "id": cty.UnknownVal(cty.String), - })) - if err != nil { - t.Fatal(err) - } - plannedState, err := msgpack.Marshal(plannedVal, schema.ImpliedType()) - if err != nil { - t.Fatal(err) - } - - config, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ - "id": cty.NullVal(cty.String), - "write_only_bar": cty.StringVal("bar"), - })) - if err != nil { - t.Fatal(err) - } - configBytes, err := msgpack.Marshal(config, schema.ImpliedType()) - if err != nil { - t.Fatal(err) - } - - testReq := &tfprotov5.ApplyResourceChangeRequest{ - TypeName: "test", - PriorState: &tfprotov5.DynamicValue{ - MsgPack: priorState, - }, - PlannedState: &tfprotov5.DynamicValue{ - MsgPack: plannedState, - }, - Config: &tfprotov5.DynamicValue{ - MsgPack: configBytes, - }, - } - - resp, err := server.ApplyResourceChange(context.Background(), testReq) - if err != nil { - t.Fatal(err) - } - - newStateVal, err := msgpack.Unmarshal(resp.NewState.MsgPack, schema.ImpliedType()) - if err != nil { - t.Fatal(err) - } - - id := newStateVal.GetAttr("id").AsString() - if id != "baz" { - t.Fatalf("incorrect final state: %#v\n", newStateVal) - } - - //nolint:staticcheck // explicitly for this SDK - if testCase.ExpectedUnsafeLegacyTypeSystem != resp.UnsafeToUseLegacyTypeSystem { - //nolint:staticcheck // explicitly for this SDK - t.Fatalf("expected UnsafeLegacyTypeSystem %t, got: %t", testCase.ExpectedUnsafeLegacyTypeSystem, resp.UnsafeToUseLegacyTypeSystem) - } - }) - } -} - func TestImportResourceState(t *testing.T) { t.Parallel() diff --git a/helper/schema/resource_data.go b/helper/schema/resource_data.go index 714a823e81..56306a190f 100644 --- a/helper/schema/resource_data.go +++ b/helper/schema/resource_data.go @@ -4,8 +4,6 @@ package schema import ( - "errors" - "fmt" "log" "reflect" "strings" @@ -15,7 +13,6 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/go-cty/cty/gocty" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -447,35 +444,6 @@ func (d *ResourceData) Timeout(key string) time.Duration { return defaultTimeout } -// GetWriteOnly returns a cty.Value for a given path to a write-only attribute. -// An error will be thrown if the path does not exist in the rawWriteOnly cty.Value -// or the rawWriteOnly value is null. If an error is thrown, the return cty.Value will -// be a null value of an empty cty object. -func (d *ResourceData) GetWriteOnly(writeOnlyPath cty.Path) (cty.Value, error) { - rawWriteOnly := d.GetRawWriteOnly() - writeOnlyVal := cty.NullVal(cty.EmptyObject) - - if rawWriteOnly.IsNull() { - return writeOnlyVal, errors.New("no write-only values exist in the config") - } - err := cty.Walk(rawWriteOnly, func(path cty.Path, value cty.Value) (bool, error) { - if path.Equals(writeOnlyPath) { - writeOnlyVal = value - return false, nil - } - return true, nil - }) - if err != nil { - return writeOnlyVal, fmt.Errorf("encountered error while retrieving write-only value %s", err) - } - - if writeOnlyVal.IsNull() { - return writeOnlyVal, fmt.Errorf("no write-only value found for given path %v", writeOnlyPath) - } - - return writeOnlyVal, nil -} - func (d *ResourceData) init() { // Initialize the field that will store our new state var copyState terraform.InstanceState @@ -669,18 +637,3 @@ func (d *ResourceData) GetRawPlan() cty.Value { } return cty.NullVal(schemaMap(d.schema).CoreConfigSchema().ImpliedType()) } - -// GetRawWriteOnly returns a cty.Value containing all write-only attributes -// values sent in the config. Since the cty.Value contains only write-only values, -// it is NOT expected to match the resource's type. -// If no value was sent, or if a null value was sent, the value will be a null -// value of an empty cty object. -// -// GetRawWriteOnly is considered experimental and advanced functionality, and -// familiarity with the Terraform protocol is suggested when using it. -func (d *ResourceData) GetRawWriteOnly() cty.Value { - if d.diff != nil && !d.diff.RawWriteOnly.IsNull() { - return d.diff.RawWriteOnly - } - return cty.NullVal(cty.EmptyObject) -} diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index cc3776abc9..c9f71081d3 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -10,8 +10,6 @@ import ( "testing" "time" - "github.com/hashicorp/go-cty/cty" - "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -758,83 +756,6 @@ func TestResourceDataGet(t *testing.T) { } } -func TestResourceDataWriteOnlyGet(t *testing.T) { - cases := map[string]struct { - RawWriteOnly cty.Value - Path cty.Path - Value cty.Value - ExpectedErr error - }{ - "null RawWriteOnly returns error": { - RawWriteOnly: cty.NullVal(cty.EmptyObject), - Path: cty.GetAttrPath("invalid_root_path"), - Value: cty.NullVal(cty.EmptyObject), - ExpectedErr: fmt.Errorf("no write-only values exist in the config"), - }, - "invalid path returns error": { - RawWriteOnly: cty.ObjectVal(map[string]cty.Value{ - "writeOnlyAttribute": cty.NumberIntVal(42), - }), - Path: cty.GetAttrPath("invalid_root_path"), - Value: cty.NullVal(cty.EmptyObject), - ExpectedErr: fmt.Errorf("no write-only value found for given path [{{} invalid_root_path}]"), - }, - "root level attribute": { - RawWriteOnly: cty.ObjectVal(map[string]cty.Value{ - "writeOnlyAttribute": cty.NumberIntVal(42), - }), - Path: cty.GetAttrPath("writeOnlyAttribute"), - Value: cty.NumberIntVal(42), - ExpectedErr: nil, - }, - "list nested block attribute - get attribute value": { - RawWriteOnly: cty.ObjectVal(map[string]cty.Value{ - "list_nested_block": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "writeOnlyAttribute": cty.StringVal("valueA"), - }), - cty.ObjectVal(map[string]cty.Value{ - "writeOnlyAttribute": cty.StringVal("valueB"), - }), - }), - }), - Path: cty.GetAttrPath("list_nested_block").IndexInt(1).GetAttr("writeOnlyAttribute"), - Value: cty.StringVal("valueB"), - ExpectedErr: nil, - }, - } - - for tn, tc := range cases { - t.Run(tn, func(t *testing.T) { - diff := &terraform.InstanceDiff{ - RawWriteOnly: tc.RawWriteOnly, - } - d := &ResourceData{ - diff: diff, - } - - v, err := d.GetWriteOnly(tc.Path) - if err == nil && tc.ExpectedErr != nil { - t.Fatalf("expected error: %s, but encountered no error", tc.ExpectedErr.Error()) - } - - if err != nil && tc.ExpectedErr == nil { - t.Fatalf("encountered unexepected error: %s", err.Error()) - } - - if err != nil && tc.ExpectedErr != nil { - if err.Error() != tc.ExpectedErr.Error() { - t.Errorf("expected error: %s not equal to encountered error: %s", tc.ExpectedErr.Error(), err.Error()) - } - } - - if !reflect.DeepEqual(v, tc.Value) { - t.Errorf("Bad: %s\n\n%#v\n\nExpected: %#v", tn, v, tc.Value) - } - }) - } -} - func TestResourceDataGetChange(t *testing.T) { cases := []struct { Schema map[string]*Schema diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index 37d73de8fe..a87db12cd8 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -210,105 +210,6 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs return diags } -// createRawWriteOnly takes a cty.Value of the config and the schema to create a new -// cty.Value with only write-only attributes values. -// MAINTAINER NOTE: The resultant cty.Value will NOT match the cty.Type of the input schema, as -// this function was only meant to create a "read-only" cty.Value for shimming. -func createRawWriteOnly(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 { - 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 - nestedVal := createRawWriteOnly(blockVal, &blockS.Block) - if nestedVal.LengthInt() > 0 { - newVals[name] = nestedVal - } - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): - listVals := blockVal.AsValueSlice() - newListVals := make([]cty.Value, 0, len(listVals)) - - for _, v := range listVals { - nestedVal := createRawWriteOnly(v, &blockS.Block) - if nestedVal.LengthInt() > 0 { - newListVals = append(newListVals, createRawWriteOnly(v, &blockS.Block)) - } - } - - switch { - case blockValType.IsSetType(): - if len(newListVals) > 0 { - newVals[name] = cty.SetVal(newListVals) - } - case blockValType.IsListType(): - if len(newListVals) > 0 { - 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 { - nestedVal := createRawWriteOnly(v, &blockS.Block) - if nestedVal.LengthInt() > 0 { - newMapVals[k] = nestedVal - } - } - - switch { - case blockValType.IsMapType(): - if len(newMapVals) > 0 { - newVals[name] = cty.MapVal(newMapVals) - } - //TODO: write a test that exercises this code. - 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 create write-only cty value for nested block %q:%#v", name, blockValType)) - } - } - - 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, path cty.Path) diag.Diagnostics { diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 3a8884b39f..03889af615 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -10,393 +10,6 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" ) -func Test_createRawWriteOnly(t *testing.T) { - for n, tc := range map[string]struct { - Schema *configschema.Block - Val cty.Value - Expected cty.Value - }{ - "Empty object returns empty object": { - &configschema.Block{}, - cty.EmptyObjectVal, - cty.EmptyObjectVal, - }, - "Top level attributes and blocks": { - &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, - }, - }, - }, - }, - "nested_block_without_write_only": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "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"), - }), - "nested_block_without_write_only": cty.ObjectVal(map[string]cty.Value{ - "required_block_attribute": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "write_only_attribute": cty.StringVal("blep"), - "nested_block": cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("blep"), - }), - }), - }, - "Set nested block: write only Nested Attribute": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "required_attribute": { - Type: cty.String, - Required: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "set_block": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "write_only_block_attribute": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "block_attribute": { - Type: cty.String, - Required: true, - }, - }, - }, - }, - "set_block_without_write_only": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "block_attribute": { - Type: cty.String, - Required: true, - }, - }, - }, - }, - }, - }, - 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.StringVal("beep"), - "block_attribute": cty.StringVal("blep"), - }), - }), - "set_block_without_write_only": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "block_attribute": cty.StringVal("blep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "set_block": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("beep"), - }), - }), - }), - }, - "Nested single block: write only nested attribute": { - &configschema.Block{ - 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, - }, - "optional_attribute": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - "nested_block_without_write_only": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "optional_attribute": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "nested_block": cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("boop"), - "optional_attribute": cty.StringVal("boop"), - }), - "nested_block_without_write_only": cty.ObjectVal(map[string]cty.Value{ - "optional_attribute": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "nested_block": cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("boop"), - }), - }), - }, - "Map nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "map_block": { - Nesting: configschema.NestingMap, - 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, - }, - }, - }, - }, - "map_block_without_write_only": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "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"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("blep"), - "write_only_block_attribute": cty.NullVal(cty.String), - }), - }), - "map_block_without_write_only": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("blep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "map_block": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.NullVal(cty.String), - }), - }), - }), - }, - "List nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "list_block": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "write_only_block_attribute": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "optional_block_attribute": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - "list_block_without_write_only": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "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{ - "write_only_block_attribute": cty.StringVal("beep"), - "optional_block_attribute": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.NullVal(cty.String), - "optional_block_attribute": cty.StringVal("blep"), - }), - }), - "list_block_without_write_only": 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"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "list_block": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("beep"), - }), - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.NullVal(cty.String), - }), - }), - }), - }, - "Set nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "set_block": { - Nesting: configschema.NestingSet, - 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, - }, - }, - }, - }, - "set_block_without_write_only": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "optional_block_attribute": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "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), - }), - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.NullVal(cty.String), - "optional_block_attribute": cty.StringVal("boop"), - }), - }), - "set_block_without_write_only": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("boop"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "set_block": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("blep"), - }), - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.NullVal(cty.String), - }), - }), - }), - }, - } { - t.Run(n, func(t *testing.T) { - got := createRawWriteOnly(tc.Val, tc.Schema) - - if !got.RawEquals(tc.Expected) { - t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) - } - }) - } -} - func Test_setWriteOnlyNullValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block diff --git a/terraform/diff.go b/terraform/diff.go index 867089b9fb..3b4179b4b3 100644 --- a/terraform/diff.go +++ b/terraform/diff.go @@ -46,11 +46,6 @@ type InstanceDiff struct { RawState cty.Value RawPlan cty.Value - // RawWriteOnly is the raw cty value of the write-only attribute values - // set in the final config. The cty.Type of this value is NOT expected to match - // the resource schema or the RawConfig / RawState / RawPlan. - RawWriteOnly cty.Value - // Meta is a simple K/V map that is stored in a diff and persisted to // plans but otherwise is completely ignored by Terraform core. It is // meant to be used for additional data a resource may want to pass through. From 20550b9f8361cc70c0b4c70b611c2d225eb69d88 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Dec 2024 17:43:07 -0500 Subject: [PATCH 30/40] Introduce `(*ResourceData).GetRawConfigAt()` helper method for retrieving write-only attributes during apply. --- helper/schema/grpc_provider_test.go | 315 ++++++++++++++++++++++++++++ helper/schema/resource_data.go | 34 +++ helper/schema/resource_data_test.go | 79 +++++++ 3 files changed, 428 insertions(+) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 68ec219825..9cb5267424 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -5094,6 +5094,88 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, + "create-writeonly-plan-modification": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { + val := d.Get("foo") + if val != "bar" { + t.Fatalf("Incorrect write-only value") + } + + return nil + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + 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.StringVal("bar"), + }), + ), + }, + 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.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &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), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, } for name, testCase := range testCases { @@ -5528,6 +5610,239 @@ func TestApplyResourceChange_bigint(t *testing.T) { } } +func TestApplyResourceChange_writeOnly(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + TestResource *Resource + ExpectedUnsafeLegacyTypeSystem bool + }{ + "Create": { + TestResource: &Resource{ + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + Create: func(rd *ResourceData, _ interface{}) error { + rd.SetId("baz") + writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %s", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "CreateContext": { + TestResource: &Resource{ + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %s", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "CreateWithoutTimeout": { + TestResource: &Resource{ + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + CreateWithoutTimeout: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %s", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "Create_cty": { + TestResource: &Resource{ + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + CreateWithoutTimeout: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + if rd.GetRawConfig().IsNull() { + return diag.FromErr(errors.New("null raw writeOnly val")) + } + if rd.GetRawConfig().GetAttr("write_only_bar").Type() != cty.String { + return diag.FromErr(errors.New("write_only_bar is not of the expected type string")) + } + writeOnlyVal := rd.GetRawConfig().GetAttr("write_only_bar").AsString() + if writeOnlyVal != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "CreateContext_SchemaFunc": { + TestResource: &Resource{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + Computed: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + } + }, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %s", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + server := NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": testCase.TestResource, + }, + }) + + schema := testCase.TestResource.CoreConfigSchema() + priorState, err := msgpack.Marshal(cty.NullVal(schema.ImpliedType()), schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + // A proposed state with only the ID unknown will produce a nil diff, and + // should return the proposed state value. + plannedVal, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + })) + if err != nil { + t.Fatal(err) + } + plannedState, err := msgpack.Marshal(plannedVal, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + config, err := schema.CoerceValue(cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "write_only_bar": cty.StringVal("bar"), + })) + if err != nil { + t.Fatal(err) + } + configBytes, err := msgpack.Marshal(config, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + testReq := &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: priorState, + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: plannedState, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: configBytes, + }, + } + + resp, err := server.ApplyResourceChange(context.Background(), testReq) + if err != nil { + t.Fatal(err) + } + + newStateVal, err := msgpack.Unmarshal(resp.NewState.MsgPack, schema.ImpliedType()) + if err != nil { + t.Fatal(err) + } + + id := newStateVal.GetAttr("id").AsString() + if id != "baz" { + t.Fatalf("incorrect final state: %#v\n", newStateVal) + } + + //nolint:staticcheck // explicitly for this SDK + if testCase.ExpectedUnsafeLegacyTypeSystem != resp.UnsafeToUseLegacyTypeSystem { + //nolint:staticcheck // explicitly for this SDK + t.Fatalf("expected UnsafeLegacyTypeSystem %t, got: %t", testCase.ExpectedUnsafeLegacyTypeSystem, resp.UnsafeToUseLegacyTypeSystem) + } + }) + } +} + func TestImportResourceState(t *testing.T) { t.Parallel() diff --git a/helper/schema/resource_data.go b/helper/schema/resource_data.go index 56306a190f..daabea396c 100644 --- a/helper/schema/resource_data.go +++ b/helper/schema/resource_data.go @@ -4,6 +4,8 @@ package schema import ( + "errors" + "fmt" "log" "reflect" "strings" @@ -13,6 +15,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/go-cty/cty/gocty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -604,6 +607,37 @@ func (d *ResourceData) GetRawConfig() cty.Value { return cty.NullVal(schemaMap(d.schema).CoreConfigSchema().ImpliedType()) } +// GetRawConfigAt is a helper method for retrieving specific values +// from the RawConfig returned from GetRawConfig. It returns the cty.Value +// for a given cty.Path or an error if the value at the given path does not exist. +// +// GetRawConfigAt is considered advanced functionality, and +// familiarity with the Terraform protocol is suggested when using it. +func (d *ResourceData) GetRawConfigAt(valPath cty.Path) (cty.Value, error) { + rawConfig := d.GetRawConfig() + configVal := cty.NullVal(cty.EmptyObject) + + if rawConfig.IsNull() { + return configVal, errors.New("the raw config is null") + } + err := cty.Walk(rawConfig, func(path cty.Path, value cty.Value) (bool, error) { + if path.Equals(valPath) { + configVal = value + return false, nil + } + return true, nil + }) + if err != nil { + return configVal, fmt.Errorf("encountered error while retrieving config value %s", err) + } + + if configVal.IsNull() { + return configVal, fmt.Errorf("no config value found for given path %v", valPath) + } + + return configVal, nil +} + // GetRawState returns the cty.Value that Terraform sent the SDK for the state. // If no value was sent, or if a null value was sent, the value will be a null // value of the resource's type. diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index c9f71081d3..4bfe234ee0 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -3915,6 +3917,83 @@ func TestResourceData_nonStringValuesInMap(t *testing.T) { } } +func TestResourceDataGetRawConfigAt(t *testing.T) { + cases := map[string]struct { + RawConfig cty.Value + Path cty.Path + Value cty.Value + ExpectedErr error + }{ + "null RawConfig returns error": { + RawConfig: cty.NullVal(cty.EmptyObject), + Path: cty.GetAttrPath("invalid_root_path"), + Value: cty.NullVal(cty.EmptyObject), + ExpectedErr: fmt.Errorf("the raw config is null"), + }, + "invalid path returns error": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.NumberIntVal(42), + }), + Path: cty.GetAttrPath("invalid_root_path"), + Value: cty.NullVal(cty.EmptyObject), + ExpectedErr: fmt.Errorf("no config value found for given path [{{} invalid_root_path}]"), + }, + "root level attribute": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.NumberIntVal(42), + }), + Path: cty.GetAttrPath("ConfigAttribute"), + Value: cty.NumberIntVal(42), + ExpectedErr: nil, + }, + "list nested block attribute - get attribute value": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.StringVal("valueA"), + }), + cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.StringVal("valueB"), + }), + }), + }), + Path: cty.GetAttrPath("list_nested_block").IndexInt(1).GetAttr("ConfigAttribute"), + Value: cty.StringVal("valueB"), + ExpectedErr: nil, + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + diff := &terraform.InstanceDiff{ + RawConfig: tc.RawConfig, + } + d := &ResourceData{ + diff: diff, + } + + v, err := d.GetRawConfigAt(tc.Path) + if err == nil && tc.ExpectedErr != nil { + t.Fatalf("expected error: %s, but encountered no error", tc.ExpectedErr.Error()) + } + + if err != nil && tc.ExpectedErr == nil { + t.Fatalf("encountered unexepected error: %s", err.Error()) + } + + if err != nil && tc.ExpectedErr != nil { + if err.Error() != tc.ExpectedErr.Error() { + t.Errorf("expected error: %s not equal to encountered error: %s", tc.ExpectedErr.Error(), err.Error()) + } + } + + if !reflect.DeepEqual(v, tc.Value) { + t.Errorf("Bad: %s\n\n%#v\n\nExpected: %#v", tn, v, tc.Value) + } + }) + } +} + func TestResourceDataSetConnInfo(t *testing.T) { d := &ResourceData{} d.SetId("foo") From 396b49b59adcad3fecf7d48f8524cea8b82f0b70 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Wed, 18 Dec 2024 10:47:47 -0500 Subject: [PATCH 31/40] null out write-only values --- helper/schema/grpc_provider.go | 1 + 1 file changed, 1 insertion(+) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 89e3990eea..4d7d223c9c 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -764,6 +764,7 @@ func (s *GRPCProviderServer) ReadResource(ctx context.Context, req *tfprotov5.Re newStateVal = normalizeNullValues(newStateVal, stateVal, false) newStateVal = copyTimeoutValues(newStateVal, stateVal) + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil { From e1dbbac8a6361a5a68a14323e22ac52780bf49e8 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 18 Dec 2024 12:28:34 -0500 Subject: [PATCH 32/40] Return `diag.Diagnostics` instead of error for `(*ResourceData).GetRawConfigAt()` --- helper/schema/grpc_provider_test.go | 8 +-- helper/schema/resource_data.go | 42 +++++++++++++--- helper/schema/resource_data_test.go | 77 +++++++++++++++++++---------- 3 files changed, 91 insertions(+), 36 deletions(-) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 9cb5267424..2606e13c64 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -5635,7 +5635,7 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { rd.SetId("baz") writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %s", err) + t.Errorf("Unable to retrieve write only attribute, err: %v", err) } if writeOnlyVal.AsString() != "bar" { t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) @@ -5663,7 +5663,7 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { rd.SetId("baz") writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %s", err) + t.Errorf("Unable to retrieve write only attribute, err: %v", err) } if writeOnlyVal.AsString() != "bar" { t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) @@ -5691,7 +5691,7 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { rd.SetId("baz") writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %s", err) + t.Errorf("Unable to retrieve write only attribute, err: %v", err) } if writeOnlyVal.AsString() != "bar" { t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) @@ -5751,7 +5751,7 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { rd.SetId("baz") writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %s", err) + t.Errorf("Unable to retrieve write only attribute, err: %v", err) } if writeOnlyVal.AsString() != "bar" { t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) diff --git a/helper/schema/resource_data.go b/helper/schema/resource_data.go index daabea396c..75a1c0c052 100644 --- a/helper/schema/resource_data.go +++ b/helper/schema/resource_data.go @@ -4,7 +4,6 @@ package schema import ( - "errors" "fmt" "log" "reflect" @@ -16,6 +15,7 @@ import ( "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/go-cty/cty/gocty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -609,16 +609,26 @@ func (d *ResourceData) GetRawConfig() cty.Value { // GetRawConfigAt is a helper method for retrieving specific values // from the RawConfig returned from GetRawConfig. It returns the cty.Value -// for a given cty.Path or an error if the value at the given path does not exist. +// for a given cty.Path or an error diagnostic if the value at the given path does not exist. // // GetRawConfigAt is considered advanced functionality, and // familiarity with the Terraform protocol is suggested when using it. -func (d *ResourceData) GetRawConfigAt(valPath cty.Path) (cty.Value, error) { +func (d *ResourceData) GetRawConfigAt(valPath cty.Path) (cty.Value, diag.Diagnostics) { rawConfig := d.GetRawConfig() configVal := cty.NullVal(cty.EmptyObject) if rawConfig.IsNull() { - return configVal, errors.New("the raw config is null") + return configVal, diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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 RawConfig is empty.", + AttributePath: valPath, + }, + } } err := cty.Walk(rawConfig, func(path cty.Path, value cty.Value) (bool, error) { if path.Equals(valPath) { @@ -628,11 +638,31 @@ func (d *ResourceData) GetRawConfigAt(valPath cty.Path) (cty.Value, error) { return true, nil }) if err != nil { - return configVal, fmt.Errorf("encountered error while retrieving config value %s", err) + return configVal, diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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" + + fmt.Sprintf("Encountered error while retrieving config value %s", err.Error()), + AttributePath: valPath, + }, + } } if configVal.IsNull() { - return configVal, fmt.Errorf("no config value found for given path %v", valPath) + return configVal, diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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" + + "Cannot find config value for given path.", + AttributePath: valPath, + }, + } } return configVal, nil diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index 4bfe234ee0..6e375030ed 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -10,8 +10,10 @@ import ( "testing" "time" + "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/terraform" ) @@ -3919,32 +3921,55 @@ func TestResourceData_nonStringValuesInMap(t *testing.T) { func TestResourceDataGetRawConfigAt(t *testing.T) { cases := map[string]struct { - RawConfig cty.Value - Path cty.Path - Value cty.Value - ExpectedErr error + RawConfig cty.Value + Path cty.Path + Value cty.Value + ExpectedDiags diag.Diagnostics }{ "null RawConfig returns error": { - RawConfig: cty.NullVal(cty.EmptyObject), - Path: cty.GetAttrPath("invalid_root_path"), - Value: cty.NullVal(cty.EmptyObject), - ExpectedErr: fmt.Errorf("the raw config is null"), + RawConfig: cty.NullVal(cty.EmptyObject), + Path: cty.GetAttrPath("invalid_root_path"), + Value: cty.NullVal(cty.EmptyObject), + ExpectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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 RawConfig is empty.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "invalid_root_path"}, + }, + }, + }, }, "invalid path returns error": { RawConfig: cty.ObjectVal(map[string]cty.Value{ "ConfigAttribute": cty.NumberIntVal(42), }), - Path: cty.GetAttrPath("invalid_root_path"), - Value: cty.NullVal(cty.EmptyObject), - ExpectedErr: fmt.Errorf("no config value found for given path [{{} invalid_root_path}]"), + Path: cty.GetAttrPath("invalid_root_path"), + Value: cty.NullVal(cty.EmptyObject), + ExpectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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" + + "Cannot find config value for given path.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "invalid_root_path"}, + }, + }, + }, }, "root level attribute": { RawConfig: cty.ObjectVal(map[string]cty.Value{ "ConfigAttribute": cty.NumberIntVal(42), }), - Path: cty.GetAttrPath("ConfigAttribute"), - Value: cty.NumberIntVal(42), - ExpectedErr: nil, + Path: cty.GetAttrPath("ConfigAttribute"), + Value: cty.NumberIntVal(42), }, "list nested block attribute - get attribute value": { RawConfig: cty.ObjectVal(map[string]cty.Value{ @@ -3957,9 +3982,8 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { }), }), }), - Path: cty.GetAttrPath("list_nested_block").IndexInt(1).GetAttr("ConfigAttribute"), - Value: cty.StringVal("valueB"), - ExpectedErr: nil, + Path: cty.GetAttrPath("list_nested_block").IndexInt(1).GetAttr("ConfigAttribute"), + Value: cty.StringVal("valueB"), }, } @@ -3972,19 +3996,20 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { diff: diff, } - v, err := d.GetRawConfigAt(tc.Path) - if err == nil && tc.ExpectedErr != nil { - t.Fatalf("expected error: %s, but encountered no error", tc.ExpectedErr.Error()) + v, diags := d.GetRawConfigAt(tc.Path) + if len(diags) == 0 && tc.ExpectedDiags == nil { + return } - if err != nil && tc.ExpectedErr == nil { - t.Fatalf("encountered unexepected error: %s", err.Error()) + if len(diags) != 0 && tc.ExpectedDiags == nil { + t.Fatalf("expected no diagnostics but got %v", diags) } - if err != nil && tc.ExpectedErr != nil { - if err.Error() != tc.ExpectedErr.Error() { - t.Errorf("expected error: %s not equal to encountered error: %s", tc.ExpectedErr.Error(), err.Error()) - } + if diff := cmp.Diff(tc.ExpectedDiags, diags, + cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), + cmp.Comparer(indexStepComparer), + ); diff != "" { + t.Errorf("Unexpected diagnostics (-wanted +got): %s", diff) } if !reflect.DeepEqual(v, tc.Value) { From 84b98735479e024e4c757381c51ccbde65e2fdc6 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 18 Dec 2024 12:36:19 -0500 Subject: [PATCH 33/40] Update `terraform-plugin-go` dependency --- go.mod | 8 ++++---- go.sum | 21 +++++++++------------ 2 files changed, 13 insertions(+), 16 deletions(-) diff --git a/go.mod b/go.mod index 3c6f5caafa..473a483548 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.1-0.20241125193751-9019c19499e0 + github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241217173851-dcf8f64dbfaa 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 @@ -49,13 +49,13 @@ require ( github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect golang.org/x/mod v0.21.0 // indirect - golang.org/x/net v0.28.0 // indirect + golang.org/x/net v0.29.0 // indirect golang.org/x/sync v0.9.0 // indirect golang.org/x/sys v0.27.0 // indirect golang.org/x/text v0.20.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-20240814211410-ddb44dafa142 // indirect - google.golang.org/grpc v1.67.1 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect + google.golang.org/grpc v1.68.1 // indirect google.golang.org/protobuf v1.35.2 // indirect ) diff --git a/go.sum b/go.sum index 3dafba24a4..c6c7c3d098 100644 --- a/go.sum +++ b/go.sum @@ -74,10 +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.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-go v0.25.1-0.20241217173851-dcf8f64dbfaa h1:GOXZVYZrfDrWxZMHdSNqZKDwayH8WBBtyOLx25ekwv8= +github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241217173851-dcf8f64dbfaa/go.mod h1:OKJU8uauqiLVRWjlFB0KIgK++baq26qfvOU1IVycx9k= 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= @@ -155,8 +153,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= +golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo= +golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= @@ -197,14 +195,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-20240814211410-ddb44dafa142 h1:e7S5W7MGGLaSu8j3YjdezkZ+m1/Nm0uRVRMEMGk26Xs= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= -google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E= -google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= +google.golang.org/grpc v1.68.1 h1:oI5oTa11+ng8r8XMMN7jAOmWfPZWbYpCFaMUTACxkM0= +google.golang.org/grpc v1.68.1/go.mod h1:+q1XYFJjShcqn0QZHvCyeR4CXPA+llXIeUIfIe00waw= 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= From 0a9d11a21404b1cd06404b8007405e426d4429cc Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 18 Dec 2024 13:31:05 -0500 Subject: [PATCH 34/40] Add additional tests for automatic write-only value nullification --- helper/schema/grpc_provider_test.go | 670 ++++++++++++++++++++++++++-- 1 file changed, 630 insertions(+), 40 deletions(-) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 2606e13c64..6d8a81b06a 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -4665,6 +4665,88 @@ func TestReadResource(t *testing.T) { }, }, }, + "write-only values are nullified in ReadResourceResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test_bool": { + Type: TypeBool, + Computed: true, + }, + "test_string": { + Type: TypeString, + Computed: true, + }, + "test_write_only": { + Type: TypeString, + WriteOnly: true, + }, + }, + ReadContext: func(ctx context.Context, d *ResourceData, meta interface{}) diag.Diagnostics { + err := d.Set("test_bool", true) + if err != nil { + return diag.FromErr(err) + } + + err = d.Set("test_string", "new-state-val") + if err != nil { + return diag.FromErr(err) + } + + err = d.Set("test_write_only", "write-only-val") + if err != nil { + return diag.FromErr(err) + } + + return nil + }, + }, + }, + }), + req: &tfprotov5.ReadResourceRequest{ + TypeName: "test", + CurrentState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test_bool": cty.Bool, + "test_string": cty.String, + "test_write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-id"), + "test_bool": cty.BoolVal(false), + "test_string": cty.StringVal("prior-state-val"), + "test_write_only": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.ReadResourceResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test_bool": cty.Bool, + "test_string": cty.String, + "test_write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("test-id"), + "test_bool": cty.BoolVal(true), + "test_string": cty.StringVal("new-state-val"), + "test_write_only": cty.NullVal(cty.String), + }), + ), + }, + }, + }, } for name, testCase := range testCases { @@ -5025,7 +5107,7 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, - "create-writeonly-required-null-values": { + "create: write-only, required attribute with null value throws error": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { @@ -5042,9 +5124,6 @@ func TestPlanResourceChange(t *testing.T) { }), req: &tfprotov5.PlanResourceChangeRequest{ TypeName: "test", - ClientCapabilities: &tfprotov5.PlanResourceChangeClientCapabilities{ - DeferralAllowed: true, - }, PriorState: &tfprotov5.DynamicValue{ MsgPack: mustMsgpackMarshal( cty.Object(map[string]cty.Type{ @@ -5094,7 +5173,7 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, - "create-writeonly-plan-modification": { + "create: write-only value can be retrieved in CustomizeDiff": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test": { @@ -5176,6 +5255,286 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, + "create: write-only values are nullified in PlanResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("baz"), + "bar": cty.StringVal("boop"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.StringVal("baz"), + "bar": cty.StringVal("boop"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.String), + "bar": cty.NullVal(cty.String), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "update: write-only value can be retrieved in CustomizeDiff": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CustomizeDiff: func(ctx context.Context, d *ResourceDiff, i interface{}) error { + val := d.Get("write_only") + if val != "bar" { + t.Fatalf("Incorrect write-only value") + } + + return nil + }, + Schema: map[string]*Schema{ + "configured": { + Type: TypeString, + Optional: true, + }, + "write_only": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("prior_val"), + "write_only": cty.NullVal(cty.String), + }), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_only": cty.StringVal("bar"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_only": cty.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_only": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_only": cty.NullVal(cty.String), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "update: write-only values are nullified in PlanResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + Schema: map[string]*Schema{ + "configured": { + Type: TypeString, + Optional: true, + }, + "write_onlyA": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "write_onlyB": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("prior_val"), + "write_onlyA": cty.NullVal(cty.String), + "write_onlyB": cty.NullVal(cty.String), + }), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.StringVal("foo"), + "write_onlyB": cty.StringVal("bar"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.StringVal("foo"), + "write_onlyB": cty.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.NullVal(cty.String), + "write_onlyB": cty.NullVal(cty.String), + }), + ), + }, + PlannedPrivate: []byte(`{"_new_extra_shim":{}}`), + RequiresReplace: []*tftypes.AttributePath{ + tftypes.NewAttributePath().WithAttributeName("id"), + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, } for name, testCase := range testCases { @@ -5289,6 +5648,237 @@ func TestPlanResourceChange_bigint(t *testing.T) { func TestApplyResourceChange(t *testing.T) { t.Parallel() + testCases := map[string]struct { + server *GRPCProviderServer + req *tfprotov5.ApplyResourceChangeRequest + expected *tfprotov5.ApplyResourceChangeResponse + }{ + "create: write-only values are nullified in ApplyResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + return nil + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + }), + ), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.StringVal("baz"), + "bar": cty.StringVal("boop"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.StringVal("baz"), + "bar": cty.StringVal("boop"), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + "bar": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("baz"), + "foo": cty.NullVal(cty.String), + "bar": cty.NullVal(cty.String), + }), + ), + }, + Private: []uint8(`{"schema_version":"4"}`), + UnsafeToUseLegacyTypeSystem: true, + }, + }, + "update: write-only values are nullified in ApplyResourceChangeResponse": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + s := rd.Get("configured").(string) + err := rd.Set("configured", s) + if err != nil { + return nil + } + return nil + }, + Schema: map[string]*Schema{ + "configured": { + Type: TypeString, + Optional: true, + }, + "write_onlyA": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "write_onlyB": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.ApplyResourceChangeRequest{ + TypeName: "test", + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("prior_val"), + "write_onlyA": cty.NullVal(cty.String), + "write_onlyB": cty.NullVal(cty.String), + }), + ), + }, + PlannedState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.StringVal("foo"), + "write_onlyB": cty.StringVal("bar"), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.StringVal("foo"), + "write_onlyB": cty.StringVal("bar"), + }), + ), + }, + }, + expected: &tfprotov5.ApplyResourceChangeResponse{ + NewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "configured": cty.String, + "write_onlyA": cty.String, + "write_onlyB": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("baz"), + "configured": cty.StringVal("updated_val"), + "write_onlyA": cty.NullVal(cty.String), + "write_onlyB": cty.NullVal(cty.String), + }), + ), + }, + Private: []uint8(`{"schema_version":"4"}`), + UnsafeToUseLegacyTypeSystem: true, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp, err := testCase.server.ApplyResourceChange(context.Background(), testCase.req) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(resp, testCase.expected, valueComparer); diff != "" { + ty := testCase.server.getResourceSchemaBlock("test").ImpliedType() + + if resp != nil && resp.NewState != nil { + t.Logf("resp.NewState.MsgPack: %s", mustMsgpackUnmarshal(ty, resp.NewState.MsgPack)) + } + + if testCase.expected != nil && testCase.expected.NewState != nil { + t.Logf("expected: %s", mustMsgpackUnmarshal(ty, testCase.expected.NewState.MsgPack)) + } + + t.Error(diff) + } + }) + } +} + +func TestApplyResourceChange_ResourceFuncs(t *testing.T) { + t.Parallel() + testCases := map[string]struct { TestResource *Resource ExpectedUnsafeLegacyTypeSystem bool @@ -5610,14 +6200,14 @@ func TestApplyResourceChange_bigint(t *testing.T) { } } -func TestApplyResourceChange_writeOnly(t *testing.T) { +func TestApplyResourceChange_ResourceFuncs_writeOnly(t *testing.T) { t.Parallel() testCases := map[string]struct { TestResource *Resource ExpectedUnsafeLegacyTypeSystem bool }{ - "Create": { + "Create: retrieve write-only value using GetRawConfigAt": { TestResource: &Resource{ SchemaVersion: 4, Schema: map[string]*Schema{ @@ -5645,7 +6235,7 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { }, ExpectedUnsafeLegacyTypeSystem: true, }, - "CreateContext": { + "CreateContext: retrieve write-only value using GetRawConfigAt": { TestResource: &Resource{ SchemaVersion: 4, Schema: map[string]*Schema{ @@ -5673,7 +6263,7 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { }, ExpectedUnsafeLegacyTypeSystem: true, }, - "CreateWithoutTimeout": { + "CreateWithoutTimeout: retrieve write-only value using GetRawConfigAt": { TestResource: &Resource{ SchemaVersion: 4, Schema: map[string]*Schema{ @@ -5701,7 +6291,36 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { }, ExpectedUnsafeLegacyTypeSystem: true, }, - "Create_cty": { + "CreateContext with SchemaFunc: retrieve write-only value using GetRawConfigAt": { + TestResource: &Resource{ + SchemaFunc: func() map[string]*Schema { + return map[string]*Schema{ + "id": { + Type: TypeString, + Computed: true, + }, + "write_only_bar": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + } + }, + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + rd.SetId("baz") + writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) + if err != nil { + t.Errorf("Unable to retrieve write only attribute, err: %v", err) + } + if writeOnlyVal.AsString() != "bar" { + t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) + } + return nil + }, + }, + ExpectedUnsafeLegacyTypeSystem: true, + }, + "CreateContext: retrieve write-only value using GetRawConfig": { TestResource: &Resource{ SchemaVersion: 4, Schema: map[string]*Schema{ @@ -5715,7 +6334,7 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { WriteOnly: true, }, }, - CreateWithoutTimeout: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { + CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { rd.SetId("baz") if rd.GetRawConfig().IsNull() { return diag.FromErr(errors.New("null raw writeOnly val")) @@ -5732,35 +6351,6 @@ func TestApplyResourceChange_writeOnly(t *testing.T) { }, ExpectedUnsafeLegacyTypeSystem: true, }, - "CreateContext_SchemaFunc": { - TestResource: &Resource{ - SchemaFunc: func() map[string]*Schema { - return map[string]*Schema{ - "id": { - Type: TypeString, - Computed: true, - }, - "write_only_bar": { - Type: TypeString, - Optional: true, - WriteOnly: true, - }, - } - }, - CreateContext: func(_ context.Context, rd *ResourceData, _ interface{}) diag.Diagnostics { - rd.SetId("baz") - writeOnlyVal, err := rd.GetRawConfigAt(cty.GetAttrPath("write_only_bar")) - if err != nil { - t.Errorf("Unable to retrieve write only attribute, err: %v", err) - } - if writeOnlyVal.AsString() != "bar" { - t.Errorf("Incorrect write-only value: expected bar but got %s", writeOnlyVal) - } - return nil - }, - }, - ExpectedUnsafeLegacyTypeSystem: true, - }, } for name, testCase := range testCases { From 64613140821bd7326c06923c6b596ac11e36f2b4 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 18 Dec 2024 13:54:21 -0500 Subject: [PATCH 35/40] Resolve linting errors and add copyright headers --- helper/schema/grpc_provider.go | 4 ++-- helper/schema/write_only.go | 25 ++++++++++++++----------- helper/schema/write_only_test.go | 12 ++++++++++-- 3 files changed, 26 insertions(+), 15 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 4d7d223c9c..0b321c70d2 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -284,7 +284,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, cty.Path{})) + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(configVal, schemaBlock, cty.Path{})) } r := s.provider.ResourcesMap[req.TypeName] @@ -850,7 +850,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, cty.Path{}) + diags := validateWriteOnlyRequiredValues(configVal, schemaBlock, cty.Path{}) if diags.HasError() { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, diags) return resp, nil diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index a87db12cd8..c14862a417 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package schema import ( @@ -116,7 +119,7 @@ func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value // // 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, path cty.Path) diag.Diagnostics { +func validateWriteOnlyNullValues(val cty.Value, schema *configschema.Block, path cty.Path) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { return diag.Diagnostics{} } @@ -141,7 +144,7 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", 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."), + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: append(path, cty.GetAttrStep{Name: name}), }) } @@ -171,7 +174,7 @@ 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, blockPath)...) + diags = append(diags, validateWriteOnlyNullValues(blockVal, &blockS.Block, blockPath)...) case blockValType.IsSetType(): setVals := blockVal.AsValueSlice() @@ -179,7 +182,7 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs setBlockPath := append(blockPath, cty.IndexStep{ Key: v, }) - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, setBlockPath)...) + diags = append(diags, validateWriteOnlyNullValues(v, &blockS.Block, setBlockPath)...) } case blockValType.IsListType(), blockValType.IsTupleType(): @@ -189,7 +192,7 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs listBlockPath := append(blockPath, cty.IndexStep{ Key: cty.NumberIntVal(int64(i)), }) - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, listBlockPath)...) + diags = append(diags, validateWriteOnlyNullValues(v, &blockS.Block, listBlockPath)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): @@ -199,7 +202,7 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs mapBlockPath := append(blockPath, cty.IndexStep{ Key: cty.StringVal(k), }) - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, mapBlockPath)...) + diags = append(diags, validateWriteOnlyNullValues(v, &blockS.Block, mapBlockPath)...) } default: @@ -212,7 +215,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, path cty.Path) diag.Diagnostics { +func validateWriteOnlyRequiredValues(val cty.Value, schema *configschema.Block, path cty.Path) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { return diag.Diagnostics{} } @@ -265,7 +268,7 @@ 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, blockPath)...) + diags = append(diags, validateWriteOnlyRequiredValues(blockVal, &blockS.Block, blockPath)...) case blockValType.IsSetType(): setVals := blockVal.AsValueSlice() @@ -273,7 +276,7 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con setBlockPath := append(blockPath, cty.IndexStep{ Key: v, }) - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, setBlockPath)...) + diags = append(diags, validateWriteOnlyRequiredValues(v, &blockS.Block, setBlockPath)...) } case blockValType.IsListType(), blockValType.IsTupleType(): @@ -283,7 +286,7 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con listBlockPath := append(blockPath, cty.IndexStep{ Key: cty.NumberIntVal(int64(i)), }) - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, listBlockPath)...) + diags = append(diags, validateWriteOnlyRequiredValues(v, &blockS.Block, listBlockPath)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): @@ -293,7 +296,7 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con mapBlockPath := append(blockPath, cty.IndexStep{ Key: cty.StringVal(k), }) - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, mapBlockPath)...) + diags = append(diags, validateWriteOnlyRequiredValues(v, &blockS.Block, mapBlockPath)...) } default: diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 03889af615..2aefbeab96 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -1,3 +1,6 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + package schema import ( @@ -16,6 +19,11 @@ func Test_setWriteOnlyNullValues(t *testing.T) { Val cty.Value Expected cty.Value }{ + "Incorrect": { + &configschema.Block{}, + cty.StringVal("s"), + cty.EmptyObjectVal, + }, "Empty returns no empty object": { &configschema.Block{}, cty.EmptyObjectVal, @@ -909,7 +917,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema, cty.Path{}) + got := validateWriteOnlyNullValues(tc.Val, tc.Schema, cty.Path{}) if diff := cmp.Diff(got, tc.Expected, cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), @@ -1465,7 +1473,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema, cty.Path{}) + got := validateWriteOnlyRequiredValues(tc.Val, tc.Schema, cty.Path{}) if diff := cmp.Diff(got, tc.Expected, cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), From 0816d60b3c19e0bf8f57be807a8075dc4e79a10f Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 18 Dec 2024 13:58:28 -0500 Subject: [PATCH 36/40] Remove "incorrect" test case --- helper/schema/write_only_test.go | 5 ----- 1 file changed, 5 deletions(-) diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 2aefbeab96..046e6a7aef 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -19,11 +19,6 @@ func Test_setWriteOnlyNullValues(t *testing.T) { Val cty.Value Expected cty.Value }{ - "Incorrect": { - &configschema.Block{}, - cty.StringVal("s"), - cty.EmptyObjectVal, - }, "Empty returns no empty object": { &configschema.Block{}, cty.EmptyObjectVal, From e844e2d4e49c41d161c6602c631be42508829a22 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 19 Dec 2024 13:11:22 -0500 Subject: [PATCH 37/40] Use `cty.DynamicVal` as default value for `GetRawConfigAt()` --- helper/schema/resource_data.go | 4 ++-- helper/schema/resource_data_test.go | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/helper/schema/resource_data.go b/helper/schema/resource_data.go index 75a1c0c052..5d15abe488 100644 --- a/helper/schema/resource_data.go +++ b/helper/schema/resource_data.go @@ -615,7 +615,7 @@ func (d *ResourceData) GetRawConfig() cty.Value { // familiarity with the Terraform protocol is suggested when using it. func (d *ResourceData) GetRawConfigAt(valPath cty.Path) (cty.Value, diag.Diagnostics) { rawConfig := d.GetRawConfig() - configVal := cty.NullVal(cty.EmptyObject) + configVal := cty.DynamicVal if rawConfig.IsNull() { return configVal, diag.Diagnostics{ @@ -651,7 +651,7 @@ func (d *ResourceData) GetRawConfigAt(valPath cty.Path) (cty.Value, diag.Diagnos } } - if configVal.IsNull() { + if configVal.RawEquals(cty.DynamicVal) { return configVal, diag.Diagnostics{ { Severity: diag.Error, diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index 6e375030ed..5210218116 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -3929,7 +3929,7 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { "null RawConfig returns error": { RawConfig: cty.NullVal(cty.EmptyObject), Path: cty.GetAttrPath("invalid_root_path"), - Value: cty.NullVal(cty.EmptyObject), + Value: cty.DynamicVal, ExpectedDiags: diag.Diagnostics{ { Severity: diag.Error, @@ -3949,7 +3949,7 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { "ConfigAttribute": cty.NumberIntVal(42), }), Path: cty.GetAttrPath("invalid_root_path"), - Value: cty.NullVal(cty.EmptyObject), + Value: cty.DynamicVal, ExpectedDiags: diag.Diagnostics{ { Severity: diag.Error, From ec6675f709c13542467f2346c52c1333bd4f78ad Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 19 Dec 2024 13:51:36 -0500 Subject: [PATCH 38/40] Throw validation error for computed blocks with write-only attributes --- helper/schema/schema.go | 4 ++++ helper/schema/schema_test.go | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 90eefe9cc2..c92ec30ec9 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -964,6 +964,10 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro case *Resource: attrsOnly := attrsOnly || v.ConfigMode == SchemaConfigModeAttr + if v.Computed && schemaMap(t.SchemaMap()).hasWriteOnly() { + return fmt.Errorf("%s: Block types with Computed set to true cannot contain WriteOnly attributes", k) + } + if err := schemaMap(t.SchemaMap()).internalValidate(topSchemaMap, attrsOnly); err != nil { return err } diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index ebe2bcc080..a178e43a47 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -5326,7 +5326,7 @@ func TestSchemaMap_InternalValidate(t *testing.T) { }, false, }, - "List computed-only block nested attribute with WriteOnly set returns error": { + "List computed block nested attribute with WriteOnly set returns error": { map[string]*Schema{ "config_block_attr": { Type: TypeList, @@ -5335,7 +5335,7 @@ func TestSchemaMap_InternalValidate(t *testing.T) { Schema: map[string]*Schema{ "nested_attr": { Type: TypeString, - Computed: true, + Optional: true, WriteOnly: true, }, }, @@ -5344,7 +5344,7 @@ func TestSchemaMap_InternalValidate(t *testing.T) { }, true, }, - "Set computed-only block nested attribute with WriteOnly set returns error": { + "Set computed block nested attribute with WriteOnly set returns error": { map[string]*Schema{ "config_block_attr": { Type: TypeSet, @@ -5353,7 +5353,7 @@ func TestSchemaMap_InternalValidate(t *testing.T) { Schema: map[string]*Schema{ "nested_attr": { Type: TypeString, - Computed: true, + Required: true, WriteOnly: true, }, }, From f529da0472198d619272fc3f0c75d78ee4863e20 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 3 Jan 2025 15:11:32 -0500 Subject: [PATCH 39/40] add `GetRawConfigAt` to `ResourceDiff` for usage in `CustomizeDiff` functions --- helper/schema/resource_diff.go | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/helper/schema/resource_diff.go b/helper/schema/resource_diff.go index 6af9490b9e..404bad49f3 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/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -480,6 +481,67 @@ func (d *ResourceDiff) GetRawConfig() cty.Value { return cty.NullVal(schemaMap(d.schema).CoreConfigSchema().ImpliedType()) } +// GetRawConfigAt is a helper method for retrieving specific values +// from the RawConfig returned from GetRawConfig. It returns the cty.Value +// for a given cty.Path or an error diagnostic if the value at the given path does not exist. +// +// GetRawConfigAt is considered advanced functionality, and +// familiarity with the Terraform protocol is suggested when using it. +func (d *ResourceDiff) GetRawConfigAt(valPath cty.Path) (cty.Value, diag.Diagnostics) { + rawConfig := d.GetRawConfig() + configVal := cty.DynamicVal + + if rawConfig.IsNull() { + return configVal, diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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 RawConfig is empty.", + AttributePath: valPath, + }, + } + } + err := cty.Walk(rawConfig, func(path cty.Path, value cty.Value) (bool, error) { + if path.Equals(valPath) { + configVal = value + return false, nil + } + return true, nil + }) + if err != nil { + return configVal, diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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" + + fmt.Sprintf("Encountered error while retrieving config value %s", err.Error()), + AttributePath: valPath, + }, + } + } + + if configVal.RawEquals(cty.DynamicVal) { + return configVal, diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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" + + "Cannot find config value for given path.", + AttributePath: valPath, + }, + } + } + + return configVal, nil +} + // GetRawState returns the cty.Value that Terraform sent the SDK for the state. // If no value was sent, or if a null value was sent, the value will be a null // value of the resource's type. From 5f29273ad6f0427161e61ebb35d4c919814cd1d6 Mon Sep 17 00:00:00 2001 From: Austin Valle Date: Fri, 3 Jan 2025 15:14:13 -0500 Subject: [PATCH 40/40] unit tests for `ResourceDiff` --- helper/schema/resource_diff_test.go | 102 ++++++++++++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/helper/schema/resource_diff_test.go b/helper/schema/resource_diff_test.go index d9a84676f4..d0ba189c0b 100644 --- a/helper/schema/resource_diff_test.go +++ b/helper/schema/resource_diff_test.go @@ -12,6 +12,8 @@ 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/terraform" ) @@ -2306,3 +2308,103 @@ func TestResourceDiffHasChanges(t *testing.T) { } } } + +func TestResourceDiffGetRawConfigAt(t *testing.T) { + cases := map[string]struct { + RawConfig cty.Value + Path cty.Path + Value cty.Value + ExpectedDiags diag.Diagnostics + }{ + "null RawConfig returns error": { + RawConfig: cty.NullVal(cty.EmptyObject), + Path: cty.GetAttrPath("invalid_root_path"), + Value: cty.DynamicVal, + ExpectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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 RawConfig is empty.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "invalid_root_path"}, + }, + }, + }, + }, + "invalid path returns error": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.NumberIntVal(42), + }), + Path: cty.GetAttrPath("invalid_root_path"), + Value: cty.DynamicVal, + ExpectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid config 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" + + "Cannot find config value for given path.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "invalid_root_path"}, + }, + }, + }, + }, + "root level attribute": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.NumberIntVal(42), + }), + Path: cty.GetAttrPath("ConfigAttribute"), + Value: cty.NumberIntVal(42), + }, + "list nested block attribute - get attribute value": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.StringVal("valueA"), + }), + cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.StringVal("valueB"), + }), + }), + }), + Path: cty.GetAttrPath("list_nested_block").IndexInt(1).GetAttr("ConfigAttribute"), + Value: cty.StringVal("valueB"), + }, + } + + for tn, tc := range cases { + t.Run(tn, func(t *testing.T) { + diff := &terraform.InstanceDiff{ + RawConfig: tc.RawConfig, + } + d := &ResourceDiff{ + diff: diff, + } + + v, diags := d.GetRawConfigAt(tc.Path) + if len(diags) == 0 && tc.ExpectedDiags == nil { + return + } + + if len(diags) != 0 && tc.ExpectedDiags == nil { + t.Fatalf("expected no diagnostics but got %v", diags) + } + + if diff := cmp.Diff(tc.ExpectedDiags, diags, + cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), + cmp.Comparer(indexStepComparer), + ); diff != "" { + t.Errorf("Unexpected diagnostics (-wanted +got): %s", diff) + } + + if !reflect.DeepEqual(v, tc.Value) { + t.Errorf("Bad: %s\n\n%#v\n\nExpected: %#v", tn, v, tc.Value) + } + }) + } +}