From 6e8402b5718c52fb2e646a204c5f08927edf79b0 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 14:13:06 -0400 Subject: [PATCH 01/58] Update `terraform-plugin-go` dependency --- go.mod | 14 +++++++------- go.sum | 8 ++++++++ 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/go.mod b/go.mod index 44908097191..fc88b9544ba 100644 --- a/go.mod +++ b/go.mod @@ -1,14 +1,14 @@ module github.com/hashicorp/terraform-plugin-sdk/v2 -go 1.21 +go 1.22 -toolchain go1.21.6 +toolchain go1.22.6 require ( github.com/google/go-cmp v0.6.0 github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 github.com/hashicorp/go-hclog v1.6.3 - github.com/hashicorp/go-plugin v1.6.0 + github.com/hashicorp/go-plugin v1.6.1 github.com/hashicorp/go-uuid v1.0.3 github.com/hashicorp/go-version v1.7.0 github.com/hashicorp/hc-install v0.8.0 @@ -16,7 +16,7 @@ require ( github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.22.1 - github.com/hashicorp/terraform-plugin-go v0.23.0 + github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/go-testing-interface v1.14.1 @@ -55,7 +55,7 @@ require ( golang.org/x/text v0.17.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de // indirect - google.golang.org/grpc v1.63.2 // indirect - google.golang.org/protobuf v1.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 // indirect + google.golang.org/grpc v1.65.0 // indirect + google.golang.org/protobuf v1.34.2 // indirect ) diff --git a/go.sum b/go.sum index c56ff86b7f5..07f42a50a96 100644 --- a/go.sum +++ b/go.sum @@ -57,6 +57,7 @@ github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+l github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -76,6 +77,10 @@ github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7 github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= +github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240827184608-0af265897829 h1:VX0f1Nh8XdurAWeN6ea7AzrVCO0mZQDPTEQdDKbDyTM= +github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240827184608-0af265897829/go.mod h1:ko0HcPe7AkwMukddKa13Qq5zyvi8V9KYxBZmqU8a9cE= +github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def h1:/RKsl9EoVaSGf4PgyuDEmnPd2f/x2jdntwx+q0kY2xA= +github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def/go.mod h1:ko0HcPe7AkwMukddKa13Qq5zyvi8V9KYxBZmqU8a9cE= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= @@ -196,12 +201,15 @@ google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAs google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From d30854f3f0bec11a629d43cf94eb2b5192b73884 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 14:16:45 -0400 Subject: [PATCH 02/58] Add `WriteOnly` attribute to schema and internal schema validation. --- helper/schema/core_schema.go | 1 + helper/schema/core_schema_test.go | 19 + helper/schema/schema.go | 51 +++ helper/schema/schema_test.go | 585 +++++++++++++++++++++++- internal/configs/configschema/schema.go | 11 + 5 files changed, 666 insertions(+), 1 deletion(-) diff --git a/helper/schema/core_schema.go b/helper/schema/core_schema.go index 736af218da2..3f12fd3b6dd 100644 --- a/helper/schema/core_schema.go +++ b/helper/schema/core_schema.go @@ -167,6 +167,7 @@ func (s *Schema) coreConfigSchemaAttribute() *configschema.Attribute { Description: desc, DescriptionKind: descKind, Deprecated: s.Deprecated != "", + WriteOnly: s.WriteOnly, } } diff --git a/helper/schema/core_schema_test.go b/helper/schema/core_schema_test.go index b8362f15ec1..76fcfa8b945 100644 --- a/helper/schema/core_schema_test.go +++ b/helper/schema/core_schema_test.go @@ -458,6 +458,25 @@ func TestSchemaMapCoreConfigSchema(t *testing.T) { BlockTypes: map[string]*configschema.NestedBlock{}, }), }, + "write-only": { + map[string]*Schema{ + "string": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + testResource(&configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "string": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{}, + }), + }, } for name, test := range tests { diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 176288b0cd8..cfb85020b2c 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -395,6 +395,17 @@ type Schema struct { // as sensitive. Any outputs containing a sensitive value must enable the // output sensitive argument. Sensitive bool + + // WriteOnly indicates that the practitioner can choose a value for this + // attribute, but Terraform will not store this attribute in state. + // If WriteOnly is true, either Optional or Required must also be true. + // + // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. + // + // This functionality is only supported in Terraform 1.XX and later. TODO: Add Terraform version + // Practitioners that choose a value for this attribute with older + // versions of Terraform will receive an error. + WriteOnly bool } // SchemaConfigMode is used to influence how a schema item is mapped into a @@ -838,6 +849,14 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro return fmt.Errorf("%s: One of optional, required, or computed must be set", k) } + if v.WriteOnly && !(v.Required || v.Optional) { + return fmt.Errorf("%s: WriteOnly must be set with either Required or Optional", k) + } + + if v.WriteOnly && v.Computed { + return fmt.Errorf("%s: WriteOnly cannot be set with Computed", k) + } + computedOnly := v.Computed && !v.Optional switch v.ConfigMode { @@ -923,6 +942,10 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro } if v.Type == TypeList || v.Type == TypeSet { + if v.WriteOnly { + return fmt.Errorf("%s: WriteOnly is not valid for lists or sets", k) + } + if v.Elem == nil { return fmt.Errorf("%s: Elem must be set for lists", k) } @@ -956,6 +979,10 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro } if v.Type == TypeMap && v.Elem != nil { + if v.WriteOnly { + return fmt.Errorf("%s: WriteOnly is not valid for maps", k) + } + switch v.Elem.(type) { case *Resource: return fmt.Errorf("%s: TypeMap with Elem *Resource not supported,"+ @@ -2353,6 +2380,30 @@ func (m schemaMap) validateType( return diags } +// hasWriteOnly returns true if the schemaMap contains any +// WriteOnly attributes are set. +func (m schemaMap) hasWriteOnly() bool { + for _, v := range m { + if v.WriteOnly { + return true + } + + if v.Elem != nil { + switch t := v.Elem.(type) { + case *Resource: + return schemaMap(t.SchemaMap()).hasWriteOnly() + case *Schema: + if t.WriteOnly { + return true + } + return schemaMap(map[string]*Schema{"nested": t}).hasWriteOnly() + } + } + } + + return false +} + // Zero returns the zero value for a type. func (t ValueType) Zero() interface{} { switch t { diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index 1c9b1f21988..6099da29d13 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -17,6 +17,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-cty/cty" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/hcl2shim" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/diagutils" @@ -3125,7 +3126,7 @@ func TestSchemaMap_InternalValidate(t *testing.T) { In map[string]*Schema Err bool }{ - "nothing": { + "nothing returns no error": { nil, false, }, @@ -5051,6 +5052,316 @@ func TestSchemaMap_InternalValidate(t *testing.T) { }, true, }, + + "Attribute with WriteOnly and Required set returns no errors": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Required: true, + WriteOnly: true, + }, + }, + false, + }, + + "Attribute with WriteOnly and Optional set returns no errors": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + false, + }, + + "Attribute with WriteOnly and Computed set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with WriteOnly, Optional, and Computed set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Computed: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with WriteOnly, Optional, Required, and Computed set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Required: true, + Computed: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with WriteOnly, Optional, and Required set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Required: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with only WriteOnly set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + WriteOnly: true, + }, + }, + true, + }, + + "List attribute with WriteOnly set returns error": { + map[string]*Schema{ + "list_attr": { + Type: TypeList, + Required: true, + WriteOnly: true, + Elem: &Schema{Type: TypeString}, + }, + }, + true, + }, + "Map attribute with WriteOnly set returns error": { + map[string]*Schema{ + "map_attr": { + Type: TypeMap, + Required: true, + WriteOnly: true, + Elem: &Schema{Type: TypeString}, + }, + }, + true, + }, + "Set attribute with WriteOnly set returns error": { + map[string]*Schema{ + "set_attr": { + Type: TypeSet, + Required: true, + WriteOnly: true, + Elem: &Schema{Type: TypeString}, + }, + }, + true, + }, + + "List configuration block with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeList, + Optional: true, + WriteOnly: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + }, + }, + }, + }, + }, + true, + }, + "List configuration block nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeList, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + false, + }, + + "Map configuration attribute with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeMap, + Optional: true, + WriteOnly: true, + Elem: &Schema{ + Type: TypeString, + Optional: true, + }, + }, + }, + true, + }, + "Map configuration attribute nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeMap, + Optional: true, + Elem: &Schema{ + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + false, + }, + + "Set configuration block with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Optional: true, + WriteOnly: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + }, + }, + }, + }, + }, + true, + }, + "Set configuration block nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + false, + }, + "List configuration block with ConfigModeAttr set, sub block nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "block": { + Type: TypeList, + ConfigMode: SchemaConfigModeAttr, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "sub": { + Type: TypeList, + ConfigMode: SchemaConfigModeAttr, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + false, + }, + + "Set configuration block with ConfigModeAttr set, sub block nested attribute with WriteOnly set returns no errors": { + map[string]*Schema{ + "block": { + Type: TypeSet, + ConfigMode: SchemaConfigModeAttr, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "sub": { + Type: TypeSet, + ConfigMode: SchemaConfigModeAttr, + Optional: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + false, + }, + "List computed-only block nested attribute with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeList, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + true, + }, + "Set computed-only block nested attribute with WriteOnly set returns error": { + map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + true, + }, } for tn, tc := range cases { @@ -8822,3 +9133,275 @@ func TestValidateRequiredWithAttributes(t *testing.T) { }) } } + +func TestHasWriteOnly(t *testing.T) { + cases := map[string]struct { + Schema map[string]*Schema + expectWriteOnly bool + }{ + "Empty returns false": { + Schema: map[string]*Schema{}, + expectWriteOnly: false, + }, + "Top-level WriteOnly set returns true": { + Schema: map[string]*Schema{ + "top-level": { + Type: TypeSet, + Optional: true, + WriteOnly: true, + MinItems: 2, + Elem: &Schema{Type: TypeString}, + }, + }, + expectWriteOnly: true, + }, + "Top-level WriteOnly not set returns false": { + Schema: map[string]*Schema{ + "top-level": { + Type: TypeSet, + Optional: true, + MinItems: 2, + Elem: &Schema{Type: TypeString}, + }, + }, + expectWriteOnly: false, + }, + "Multiple top-level WriteOnly set returns true": { + Schema: map[string]*Schema{ + "top-level1": { + Type: TypeSet, + Optional: true, + MinItems: 2, + Elem: &Schema{Type: TypeString}, + }, + "top-level2": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + "top-level3": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + MinItems: 2, + Elem: &Schema{Type: TypeString}, + }, + }, + expectWriteOnly: true, + }, + "Elem set with Resource: no WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + }, + }, + }, + }, + }, + expectWriteOnly: false, + }, + "Elem set with Resource: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + expectWriteOnly: true, + }, + "Double nested Elem set with Resource: no WriteOnly returns false": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_nested_attr": { + Type: TypeString, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + expectWriteOnly: false, + }, + "Double nested Elem set with Resource: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_nested_attr": { + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + expectWriteOnly: true, + }, + "Elem set with Schema: no WriteOnly returns false": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Computed: true, + }, + }, + }, + expectWriteOnly: false, + }, + "Elem set with Schema: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Computed: true, + WriteOnly: true, + }, + }, + }, + expectWriteOnly: true, + }, + "Double nested Elem set with Schema: no WriteOnly returns false": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Elem: &Schema{ + Computed: true, + }, + }, + }, + }, + expectWriteOnly: false, + }, + "Double nested Elem set with Schema: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Elem: &Schema{ + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + expectWriteOnly: true, + }, + "Multiple nested elements: no WriteOnly returns false": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Elem: &Schema{ + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_nested_nested_attr": { + Type: TypeString, + Computed: true, + }, + "nested_nested_nested_attr2": { + Type: TypeString, + Computed: true, + Elem: &Schema{ + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }, + expectWriteOnly: false, + }, + "Multiple nested elements: WriteOnly returns true": { + Schema: map[string]*Schema{ + "config_block_attr": { + Type: TypeSet, + Computed: true, + Elem: &Schema{ + Type: TypeString, + Elem: &Schema{ + Computed: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_nested_nested_attr": { + Type: TypeString, + Computed: true, + }, + "nested_nested_nested_attr2": { + Type: TypeString, + Computed: true, + Elem: &Schema{ + Computed: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + }, + }, + expectWriteOnly: true, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + actualWriteOnly := schemaMap(tc.Schema).hasWriteOnly() + if tc.expectWriteOnly != actualWriteOnly { + t.Fatalf("Expected: %t, got: %t", tc.expectWriteOnly, actualWriteOnly) + } + }) + } +} diff --git a/internal/configs/configschema/schema.go b/internal/configs/configschema/schema.go index c445b4ba55e..a45c5cc241d 100644 --- a/internal/configs/configschema/schema.go +++ b/internal/configs/configschema/schema.go @@ -83,6 +83,17 @@ type Attribute struct { // Deprecated indicates whether the attribute has been marked as deprecated in the // provider and usage should be discouraged. Deprecated bool + + // WriteOnly indicates that the practitioner can choose a value for this + // attribute, but Terraform will not store this attribute in state. + // If WriteOnly is true, either Optional or Required must also be true. + // + // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. + // + // This functionality is only supported in Terraform 1.XX and later. TODO: add Terraform version + // Practitioners that choose a value for this attribute with older + // versions of Terraform will receive an error. + WriteOnly bool } // NestedBlock represents the embedding of one block within another. From 17b4a61a9490f894d14acc2eab50960698764b54 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 14:21:25 -0400 Subject: [PATCH 03/58] Add `WriteOnly` validation for data source, provider, and provider meta schemas. --- helper/schema/provider.go | 18 ++++++ helper/schema/provider_test.go | 111 +++++++++++++++++++++++++++------ helper/schema/resource_test.go | 75 ++++++++++------------ 3 files changed, 145 insertions(+), 59 deletions(-) diff --git a/helper/schema/provider.go b/helper/schema/provider.go index a75ae2fc28b..498d0f55429 100644 --- a/helper/schema/provider.go +++ b/helper/schema/provider.go @@ -13,6 +13,7 @@ import ( "strings" "github.com/hashicorp/terraform-plugin-go/tfprotov5" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" @@ -192,11 +193,23 @@ func (p *Provider) InternalValidate() error { } var validationErrors []error + + // Provider schema validation sm := schemaMap(p.Schema) if err := sm.InternalValidate(sm); err != nil { validationErrors = append(validationErrors, err) } + if sm.hasWriteOnly() { + validationErrors = append(validationErrors, fmt.Errorf("provider schema cannot contain WriteOnly attributes")) + } + + // Provider meta schema validation + providerMeta := schemaMap(p.ProviderMetaSchema) + if providerMeta.hasWriteOnly() { + validationErrors = append(validationErrors, fmt.Errorf("provider meta schema cannot contain WriteOnly attributes")) + } + // Provider-specific checks for k := range sm { if isReservedProviderFieldName(k) { @@ -214,6 +227,11 @@ func (p *Provider) InternalValidate() error { if err := r.InternalValidate(nil, false); err != nil { validationErrors = append(validationErrors, fmt.Errorf("data source %s: %s", k, err)) } + + dataSourceSchema := schemaMap(r.SchemaMap()) + if dataSourceSchema.hasWriteOnly() { + validationErrors = append(validationErrors, fmt.Errorf("data source %s cannot contain WriteOnly attributes", k)) + } } return errors.Join(validationErrors...) diff --git a/helper/schema/provider_test.go b/helper/schema/provider_test.go index dcab8acd71b..00de0894272 100644 --- a/helper/schema/provider_test.go +++ b/helper/schema/provider_test.go @@ -2288,11 +2288,11 @@ func TestProviderMeta(t *testing.T) { } func TestProvider_InternalValidate(t *testing.T) { - cases := []struct { + cases := map[string]struct { P *Provider ExpectedErr error }{ - { + "Provider with schema returns no errors": { P: &Provider{ Schema: map[string]*Schema{ "foo": { @@ -2303,7 +2303,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: nil, }, - { // Reserved resource fields should be allowed in provider block + "Reserved resource fields in provider block returns no errors": { P: &Provider{ Schema: map[string]*Schema{ "provisioner": { @@ -2318,7 +2318,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: nil, }, - { // Reserved provider fields should not be allowed + "Reserved provider fields returns an error": { // P: &Provider{ Schema: map[string]*Schema{ "alias": { @@ -2329,7 +2329,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: fmt.Errorf("%s is a reserved field name for a provider", "alias"), }, - { // ConfigureFunc and ConfigureContext cannot both be set + "Provider with ConfigureFunc and ConfigureContext both set returns an error": { P: &Provider{ Schema: map[string]*Schema{ "foo": { @@ -2346,22 +2346,97 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: fmt.Errorf("ConfigureFunc and ConfigureContextFunc must not both be set"), }, + "Provider schema with WriteOnly attribute set returns an error": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + ExpectedErr: fmt.Errorf("provider schema cannot contain WriteOnly attributes"), + }, + "Provider meta schema with WriteOnly attribute set returns an error": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + ProviderMetaSchema: map[string]*Schema{ + "meta-foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + ExpectedErr: fmt.Errorf("provider meta schema cannot contain WriteOnly attributes"), + }, + "Data source schema with WriteOnly attribute set returns an error": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + DataSourcesMap: map[string]*Resource{ + "data-foo": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + ExpectedErr: fmt.Errorf("data source data-foo cannot contain WriteOnly attributes"), + }, + "Resource schema with WriteOnly attribute set returns no errors": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + ResourcesMap: map[string]*Resource{ + "resource-foo": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + ExpectedErr: nil, + }, } - for i, tc := range cases { - err := tc.P.InternalValidate() - if tc.ExpectedErr == nil { - if err != nil { - t.Fatalf("%d: Error returned (expected no error): %s", i, err) + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + err := tc.P.InternalValidate() + if tc.ExpectedErr == nil { + if err != nil { + t.Fatalf("Error returned (expected no error): %s", err) + } } - continue - } - if tc.ExpectedErr != nil && err == nil { - t.Fatalf("%d: Expected error (%s), but no error returned", i, tc.ExpectedErr) - } - if err.Error() != tc.ExpectedErr.Error() { - t.Fatalf("%d: Errors don't match. Expected: %#v Given: %#v", i, tc.ExpectedErr, err) - } + if tc.ExpectedErr != nil && err == nil { + t.Fatalf("Expected error (%s), but no error returned", tc.ExpectedErr) + } + if tc.ExpectedErr != nil && err.Error() != tc.ExpectedErr.Error() { + t.Fatalf("Errors don't match. Expected: %#v Given: %#v", tc.ExpectedErr.Error(), err.Error()) + } + }) } } diff --git a/helper/schema/resource_test.go b/helper/schema/resource_test.go index 5c9fd629ba9..447338e6d52 100644 --- a/helper/schema/resource_test.go +++ b/helper/schema/resource_test.go @@ -635,19 +635,18 @@ func TestResourceApply_isNewResource(t *testing.T) { } func TestResourceInternalValidate(t *testing.T) { - cases := []struct { + cases := map[string]struct { In *Resource Writable bool Err bool }{ - 0: { + "nil": { nil, true, true, }, - // No optional and no required - 1: { + "No optional and no required": { &Resource{ Schema: map[string]*Schema{ "foo": { @@ -661,8 +660,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // Update undefined for non-ForceNew field - 2: { + "Update undefined for non-ForceNew field": { &Resource{ Create: Noop, Schema: map[string]*Schema{ @@ -676,8 +674,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // Update defined for ForceNew field - 3: { + "Update defined for ForceNew field": { &Resource{ Create: Noop, Update: Noop, @@ -693,8 +690,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // non-writable doesn't need Update, Create or Delete - 4: { + "non-writable doesn't need Update, Create or Delete": { &Resource{ Schema: map[string]*Schema{ "goo": { @@ -707,8 +703,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - // non-writable *must not* have Create - 5: { + "non-writable *must not* have Create": { &Resource{ Create: Noop, Schema: map[string]*Schema{ @@ -722,8 +717,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // writable must have Read - 6: { + "writable must have Read": { &Resource{ Create: Noop, Update: Noop, @@ -739,8 +733,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - // writable must have Delete - 7: { + "writable must have Delete": { &Resource{ Create: Noop, Read: Noop, @@ -756,7 +749,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - 8: { // Reserved name at root should be disallowed + "Reserved name at root should be disallowed": { &Resource{ Create: Noop, Read: Noop, @@ -773,7 +766,7 @@ func TestResourceInternalValidate(t *testing.T) { true, }, - 9: { // Reserved name at nested levels should be allowed + "Reserved name at nested levels should be allowed": { &Resource{ Create: Noop, Read: Noop, @@ -798,7 +791,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - 10: { // Provider reserved name should be allowed in resource + "Provider reserved name should be allowed in resource": { &Resource{ Create: Noop, Read: Noop, @@ -815,7 +808,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - 11: { // ID should be allowed in data source + "ID should be allowed in data source": { &Resource{ Read: Noop, Schema: map[string]*Schema{ @@ -829,7 +822,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - 12: { // Deprecated ID should be allowed in resource + "Deprecated ID should be allowed in resource": { &Resource{ Create: Noop, Read: Noop, @@ -848,7 +841,7 @@ func TestResourceInternalValidate(t *testing.T) { false, }, - 13: { // non-writable must not define CustomizeDiff + "non-writable must not define CustomizeDiff": { &Resource{ Read: Noop, Schema: map[string]*Schema{ @@ -862,7 +855,7 @@ func TestResourceInternalValidate(t *testing.T) { false, true, }, - 14: { // Deprecated resource + "Deprecated resource": { &Resource{ Read: Noop, Schema: map[string]*Schema{ @@ -876,7 +869,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 15: { // Create and CreateContext should not both be set + "Create and CreateContext should not both be set": { &Resource{ Create: Noop, CreateContext: NoopContext, @@ -893,7 +886,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 16: { // Read and ReadContext should not both be set + "Read and ReadContext should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -910,7 +903,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 17: { // Update and UpdateContext should not both be set + "Update and UpdateContext should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -927,7 +920,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 18: { // Delete and DeleteContext should not both be set + "Delete and DeleteContext should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -944,7 +937,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 19: { // Create and CreateWithoutTimeout should not both be set + "Create and CreateWithoutTimeout should not both be set": { &Resource{ Create: Noop, CreateWithoutTimeout: NoopContext, @@ -961,7 +954,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 20: { // Read and ReadWithoutTimeout should not both be set + "Read and ReadWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -978,7 +971,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 21: { // Update and UpdateWithoutTimeout should not both be set + "Update and UpdateWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -995,7 +988,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 22: { // Delete and DeleteWithoutTimeout should not both be set + "Delete and DeleteWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -1012,7 +1005,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 23: { // CreateContext and CreateWithoutTimeout should not both be set + "CreateContext and CreateWithoutTimeout should not both be set": { &Resource{ CreateContext: NoopContext, CreateWithoutTimeout: NoopContext, @@ -1029,7 +1022,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 24: { // ReadContext and ReadWithoutTimeout should not both be set + "ReadContext and ReadWithoutTimeout should not both be set": { &Resource{ Create: Noop, ReadContext: NoopContext, @@ -1046,7 +1039,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 25: { // UpdateContext and UpdateWithoutTimeout should not both be set + "UpdateContext and UpdateWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -1063,7 +1056,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 26: { // DeleteContext and DeleteWithoutTimeout should not both be set + "DeleteContext and DeleteWithoutTimeout should not both be set": { &Resource{ Create: Noop, Read: Noop, @@ -1080,7 +1073,7 @@ func TestResourceInternalValidate(t *testing.T) { true, true, }, - 27: { // Non-Writable SchemaFunc and Schema should not both be set + "Non-Writable SchemaFunc and Schema should not both be set": { In: &Resource{ Schema: map[string]*Schema{ "test": { @@ -1101,7 +1094,7 @@ func TestResourceInternalValidate(t *testing.T) { Writable: false, Err: true, }, - 28: { // Writable SchemaFunc and Schema should not both be set + "Writable SchemaFunc and Schema should not both be set": { In: &Resource{ Schema: map[string]*Schema{ "test": { @@ -1127,18 +1120,18 @@ func TestResourceInternalValidate(t *testing.T) { }, } - for i, tc := range cases { - t.Run(fmt.Sprintf("#%d", i), func(t *testing.T) { + for name, tc := range cases { + t.Run(name, func(t *testing.T) { sm := schemaMap{} if tc.In != nil { sm = schemaMap(tc.In.Schema) } err := tc.In.InternalValidate(sm, tc.Writable) if err != nil && !tc.Err { - t.Fatalf("%d: expected validation to pass: %s", i, err) + t.Fatalf("%s: expected validation to pass: %s", name, err) } if err == nil && tc.Err { - t.Fatalf("%d: expected validation to fail", i) + t.Fatalf("%s: expected validation to fail", name) } }) } From 52183216c83a04c56d3c9473a0de1d1b7ce81c08 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 15:05:26 -0400 Subject: [PATCH 04/58] Add WriteOnly capabilities validation to `ValidateResourceTypeConfig` RPC --- helper/schema/grpc_provider.go | 135 ++++++ helper/schema/grpc_provider_test.go | 618 +++++++++++++++++++++++++++- 2 files changed, 752 insertions(+), 1 deletion(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index ec5d74301a7..4f7f583a6aa 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -17,6 +17,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/hcl2shim" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" @@ -281,6 +282,9 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) return resp, nil } + if req.ClientCapabilities == nil || !req.ClientCapabilities.WriteOnlyAttributesAllowed { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock)) + } config := terraform.NewResourceConfigShimmed(configVal, schemaBlock) @@ -1482,6 +1486,78 @@ func (s *GRPCProviderServer) GetFunctions(ctx context.Context, req *tfprotov5.Ge return resp, nil } +func (s *GRPCProviderServer) ValidateEphemeralResourceConfig(ctx context.Context, req *tfprotov5.ValidateEphemeralResourceConfigRequest) (*tfprotov5.ValidateEphemeralResourceConfigResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Returning error for provider validate ephemeral resource call") + + resp := &tfprotov5.ValidateEphemeralResourceConfigResponse{} + + resp.Diagnostics = []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Ephemeral Resource Not Found ", + Detail: fmt.Sprintf("No ephemeral resource type named %q was found in the provider", req.TypeName), + }, + } + + return resp, nil +} + +func (s *GRPCProviderServer) OpenEphemeralResource(ctx context.Context, req *tfprotov5.OpenEphemeralResourceRequest) (*tfprotov5.OpenEphemeralResourceResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Returning error for provider open ephemeral resource call") + + resp := &tfprotov5.OpenEphemeralResourceResponse{} + + resp.Diagnostics = []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Ephemeral Resource Not Found ", + Detail: fmt.Sprintf("No ephemeral resource type named %q was found in the provider", req.TypeName), + }, + } + + return resp, nil +} + +func (s *GRPCProviderServer) RenewEphemeralResource(ctx context.Context, req *tfprotov5.RenewEphemeralResourceRequest) (*tfprotov5.RenewEphemeralResourceResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Returning error for provider renew ephemeral resource call") + + resp := &tfprotov5.RenewEphemeralResourceResponse{} + + resp.Diagnostics = []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Ephemeral Resource Not Found ", + Detail: fmt.Sprintf("No ephemeral resource type named %q was found in the provider", req.TypeName), + }, + } + + return resp, nil +} + +func (s *GRPCProviderServer) CloseEphemeralResource(ctx context.Context, req *tfprotov5.CloseEphemeralResourceRequest) (*tfprotov5.CloseEphemeralResourceResponse, error) { + ctx = logging.InitContext(ctx) + + logging.HelperSchemaTrace(ctx, "Returning error for provider close ephemeral resource call") + + resp := &tfprotov5.CloseEphemeralResourceResponse{} + + resp.Diagnostics = []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Ephemeral Resource Not Found ", + Detail: fmt.Sprintf("No ephemeral resource type named %q was found in the provider", req.TypeName), + }, + } + + return resp, nil +} + func pathToAttributePath(path cty.Path) *tftypes.AttributePath { var steps []tftypes.AttributePathStep @@ -1828,3 +1904,62 @@ func configureDeferralAllowed(in *tfprotov5.ConfigureProviderClientCapabilities) return in.DeferralAllowed } + +// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for each non-null writeOnly attribute value. +func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { + if !val.IsKnown() || val.IsNull() { + return diag.Diagnostics{} + } + + valMap := val.AsValueMap() + diags := make([]diag.Diagnostic, 0) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && !v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), + }) + } + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + continue + } + + blockValType := blockVal.Type() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + + for _, v := range listVals { + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + } + + default: + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + } + } + + return diags +} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 7dacdd6ea5c..f9af8c9a8dd 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -17,10 +17,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/go-cty/cty/msgpack" - "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/plugin/convert" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -3485,6 +3486,255 @@ func TestGRPCProviderServerMoveResourceState(t *testing.T) { } } +func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + server *GRPCProviderServer + request *tfprotov5.ValidateResourceTypeConfigRequest + expected *tfprotov5.ValidateResourceTypeConfigResponse + }{ + "Provider with empty resource returns no errors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": {}, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{}, + }, + "Server without WriteOnlyAttributesAllowed capabilities: null WriteOnly attribute returns no errors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.Number), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{}, + }, + "Server without WriteOnlyAttributesAllowed capabilities: WriteOnly Attribute with Value returns an error": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + }, + }, + }, + "Server without WriteOnlyAttributesAllowed capabilities: multiple WriteOnly Attributes with Value returns multiple errors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + }, + "Server without WriteOnlyAttributesAllowed capabilities: multiple nested WriteOnly Attributes with Value returns multiple errors": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + }, + "config_block_attr": { + Type: TypeList, + Optional: true, + WriteOnly: true, + Elem: &Resource{ + Schema: map[string]*Schema{ + "nested_attr": { + Type: TypeString, + Optional: true, + }, + "writeonly_nested_attr": { + Type: TypeString, + WriteOnly: true, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + "config_block_attr": cty.List(cty.Object(map[string]cty.Type{ + "nested_attr": cty.String, + "writeonly_nested_attr": cty.String, + })), + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + "config_block_attr": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + "writeonly_nested_attr": cty.StringVal("value"), + }), + }), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\"", + }, + }, + }, + }, + } + + for name, testCase := range testCases { + name, testCase := name, testCase + + t.Run(name, func(t *testing.T) { + t.Parallel() + + resp, err := testCase.server.ValidateResourceTypeConfig(context.Background(), testCase.request) + + if testCase.request != nil && err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if diff := cmp.Diff(resp, testCase.expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func TestUpgradeState_jsonState(t *testing.T) { r := &Resource{ SchemaVersion: 2, @@ -6950,6 +7200,372 @@ func Test_pathToAttributePath_noSteps(t *testing.T) { } } +func Test_validateWriteOnlyValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected diag.Diagnostics + }{ + "Empty returns no diags": { + &configschema.Block{}, + cty.EmptyObjectVal, + diag.Diagnostics{}, + }, + "All null values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + diag.Diagnostics{}, + }, + "Set nested block WriteOnly attribute with value returns diag": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("foo_val"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", + }, + }, + }, + "Nested single block, WriteOnly attribute with value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("beep"), + "baz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.StringVal("boop2"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with dynamic value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NumberIntVal(8), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attributes not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + } { + t.Run(n, func(t *testing.T) { + got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) + + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func mustMsgpackMarshal(ty cty.Type, val cty.Value) []byte { result, err := msgpack.Marshal(val, ty) From bb2bb085c39a6345178b3e3d2ed3bd4375ab6658 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 15:17:10 -0400 Subject: [PATCH 05/58] Skip value validation for `Required` + `WriteOnly` attributes. --- helper/schema/schema.go | 2 +- helper/schema/schema_test.go | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index cfb85020b2c..9cffc321bbc 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -1725,7 +1725,7 @@ func (m schemaMap) validate( } if !ok { - if schema.Required { + if schema.Required && !schema.WriteOnly { return append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: "Missing required argument", diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index 6099da29d13..ebe2bcc0801 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -7076,6 +7076,41 @@ func TestSchemaMap_Validate(t *testing.T) { }, }, }, + "Required + WriteOnly attribute with null value returns no errors": { + Schema: map[string]*Schema{ + "write_only_attribute": { + Type: TypeString, + Required: true, + WriteOnly: true, + }, + }, + + Config: nil, + }, + "Required + WriteOnly attribute with default func returns no errors": { + Schema: map[string]*Schema{ + "write_only_attribute": { + Type: TypeString, + Required: true, + WriteOnly: true, + DefaultFunc: func() (interface{}, error) { return "default", nil }, + }, + }, + + Config: nil, + }, + "Required + WriteOnly attribute with default func nil value returns no errors": { + Schema: map[string]*Schema{ + "write_only_attribute": { + Type: TypeString, + Required: true, + WriteOnly: true, + DefaultFunc: func() (interface{}, error) { return nil, nil }, + }, + }, + + Config: nil, + }, } for tn, tc := range cases { From 1d7083181b7a37a471f61e9ba5a8a62e13739082 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 30 Aug 2024 15:38:35 -0400 Subject: [PATCH 06/58] Fix intermittent test failures for `hasWriteOnly()` --- go.sum | 16 ++++------------ helper/schema/schema.go | 6 +++++- 2 files changed, 9 insertions(+), 13 deletions(-) diff --git a/go.sum b/go.sum index 07f42a50a96..a7a020da742 100644 --- a/go.sum +++ b/go.sum @@ -55,8 +55,7 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.6.0 h1:wgd4KxHJTVGGqWBq4QPB1i5BZNEx9BR8+OFmHDmTk8A= -github.com/hashicorp/go-plugin v1.6.0/go.mod h1:lBS5MtSSBZk0SHc66KACcjjlU6WzEVP/8pwz68aMkCI= +github.com/hashicorp/go-plugin v1.6.1 h1:P7MR2UP6gNKGPp+y7EZw2kOiq4IR9WiqLvp0XOsVdwI= github.com/hashicorp/go-plugin v1.6.1/go.mod h1:XPHFku2tFo3o3QKFgSYo+cghcUhw1NA1hZyMK0PWAw0= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= @@ -75,10 +74,6 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= -github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= -github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= -github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240827184608-0af265897829 h1:VX0f1Nh8XdurAWeN6ea7AzrVCO0mZQDPTEQdDKbDyTM= -github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240827184608-0af265897829/go.mod h1:ko0HcPe7AkwMukddKa13Qq5zyvi8V9KYxBZmqU8a9cE= github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def h1:/RKsl9EoVaSGf4PgyuDEmnPd2f/x2jdntwx+q0kY2xA= github.com/hashicorp/terraform-plugin-go v0.23.1-0.20240830180900-5eafe6ae6def/go.mod h1:ko0HcPe7AkwMukddKa13Qq5zyvi8V9KYxBZmqU8a9cE= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= @@ -199,16 +194,13 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de h1:cZGRis4/ot9uVm639a+rHCUaG0JJHEsdyzSQTMX+suY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20240227224415-6ceb2ff114de/go.mod h1:H4O17MA/PE9BsGx3w+a+W2VOLLD1Qf7oJneAoU6WktY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157 h1:Zy9XzmMEflZ/MAaA7vNcoebnRAld7FsPW1EeBB7V0m8= google.golang.org/genproto/googleapis/rpc v0.0.0-20240528184218-531527333157/go.mod h1:EfXuqaE1J41VCDicxHzUDm+8rk+7ZdXzHV0IhO/I6s0= -google.golang.org/grpc v1.63.2 h1:MUeiw1B2maTVZthpU5xvASfTh3LDbxHd6IJ6QQVU+xM= -google.golang.org/grpc v1.63.2/go.mod h1:WAX/8DgncnokcFUldAxq7GeB5DXHDbMF+lLvDomNkRA= +google.golang.org/grpc v1.65.0 h1:bs/cUb4lp1G5iImFFd3u5ixQzweKizoZJAwBNLR42lc= google.golang.org/grpc v1.65.0/go.mod h1:WgYC2ypjlB0EiQi6wdKixMqukr6lBc0Vo+oOgjrM5ZQ= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.34.0 h1:Qo/qEd2RZPCf2nKuorzksSknv0d3ERwp1vFG38gSmH4= -google.golang.org/protobuf v1.34.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg= google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 9cffc321bbc..8dde0469029 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -2396,7 +2396,11 @@ func (m schemaMap) hasWriteOnly() bool { if t.WriteOnly { return true } - return schemaMap(map[string]*Schema{"nested": t}).hasWriteOnly() + + isNestedWriteOnly := schemaMap(map[string]*Schema{"nested": t}).hasWriteOnly() + if isNestedWriteOnly { + return true + } } } } From e7d79dbcf8f0fbf53be4d665a71f6bce01b46245 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 3 Sep 2024 17:44:20 -0400 Subject: [PATCH 07/58] Validate non-null values for `Required` and `WriteOnly` attributes in `PlanResourceChange()` --- helper/schema/grpc_provider.go | 69 +++++ helper/schema/grpc_provider_test.go | 441 +++++++++++++++++++++++++++- 2 files changed, 509 insertions(+), 1 deletion(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 4f7f583a6aa..2f82c6c75b5 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -822,6 +822,16 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot return resp, nil } + // If the resource is being created, validate that all required write-only + // attributes in the config have non-nil values. + if create { + diags := validateWriteOnlyRequiredValues(req.TypeName, configVal, schemaBlock) + if diags.HasError() { + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, diags) + return resp, nil + } + } + priorState, err := res.ShimInstanceStateFromValue(priorStateVal) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) @@ -1963,3 +1973,62 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs return diags } + +// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for every WriteOnly + Required attribute null value. +func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { + if !val.IsKnown() || val.IsNull() { + return diag.Diagnostics{} + } + + valMap := val.AsValueMap() + diags := make([]diag.Diagnostic, 0) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && attr.Required && v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + }) + } + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + continue + } + + blockValType := blockVal.Type() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + + for _, v := range listVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + } + + default: + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + } + } + + return diags +} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index f9af8c9a8dd..3ee32217e49 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -4789,6 +4789,74 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, + "create-writeonly-required-null-values": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 4, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }), + req: &tfprotov5.PlanResourceChangeRequest{ + TypeName: "test", + ClientCapabilities: &tfprotov5.PlanResourceChangeClientCapabilities{ + DeferralAllowed: true, + }, + PriorState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + cty.NullVal( + cty.Object(map[string]cty.Type{ + "foo": cty.String, + }), + ), + ), + }, + ProposedNewState: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.UnknownVal(cty.String), + "foo": cty.NullVal(cty.String), + }), + ), + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NullVal(cty.String), + }), + ), + }, + }, + expected: &tfprotov5.PlanResourceChangeResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test\" resource contains a null value for Required WriteOnly attribute \"foo\"", + }, + }, + UnsafeToUseLegacyTypeSystem: true, + }, + }, } for name, testCase := range testCases { @@ -7200,7 +7268,7 @@ func Test_pathToAttributePath_noSteps(t *testing.T) { } } -func Test_validateWriteOnlyValues(t *testing.T) { +func Test_validateWriteOnlyNullValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block Val cty.Value @@ -7566,6 +7634,377 @@ func Test_validateWriteOnlyValues(t *testing.T) { } } +func Test_validateWriteOnlyRequiredValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected diag.Diagnostics + }{ + "Empty returns no diags": { + &configschema.Block{}, + cty.EmptyObjectVal, + diag.Diagnostics{}, + }, + "All Required + WriteOnly with values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.StringVal("blep"), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + "biz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{}, + }, + "All Optional + WriteOnly with null values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + diag.Diagnostics{}, + }, + "Set nested block Required + WriteOnly attribute with null return diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"foo\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"boz\"", + }, + }, + }, + "Nested single block, Required + WriteOnly attribute with null returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, Required + WriteOnly attribute with null value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + }, + }, + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + } { + t.Run(n, func(t *testing.T) { + got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) + + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + func mustMsgpackMarshal(ty cty.Type, val cty.Value) []byte { result, err := msgpack.Marshal(val, ty) From d171fb75a5aa0fdb946f56e9509a07eab2a1024b Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 5 Sep 2024 15:53:20 -0400 Subject: [PATCH 08/58] Add initial implementation for `PreferWriteOnlyAttribute()` validator --- helper/schema/schema.go | 25 ++ helper/validation/write_only.go | 143 ++++++++++++ helper/validation/write_only_test.go | 326 +++++++++++++++++++++++++++ 3 files changed, 494 insertions(+) create mode 100644 helper/validation/write_only.go create mode 100644 helper/validation/write_only_test.go diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 8dde0469029..fe29e42e6c3 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -371,6 +371,13 @@ type Schema struct { // AttributePath: append(path, cty.IndexStep{Key: cty.StringVal("key_name")}) ValidateDiagFunc SchemaValidateDiagFunc + // ValidateResourceConfig allows a function to define arbitrary validation + // logic during the ValidateResourceTypeConfig RPC. ValidateResourceConfigFunc receives + // the client capabilities from the ValidateResourceTypeConfig RPC and the raw cty + // config value for the entire resource before it is shimmed, and it can return error + // diagnostics based on the inspection of those values. + ValidateResourceConfig ValidateResourceConfigFunc + // Sensitive ensures that the attribute's value does not get displayed in // the Terraform user interface output. It should be used for password or // other values which should be hidden. @@ -476,6 +483,24 @@ type SchemaValidateFunc func(interface{}, string) ([]string, []error) // schema and has Diagnostic support. type SchemaValidateDiagFunc func(interface{}, cty.Path) diag.Diagnostics +// ValidateResourceConfigFunc is a function used to validate the raw resource config +// and has Diagnostic support. +type ValidateResourceConfigFunc func(context.Context, ValidateResourceConfigRequest, *ValidateResourceConfigResponse) + +type ValidateResourceConfigRequest struct { + // WriteOnlyAttributesAllowed indicates that the Terraform client + // initiating the request supports write-only attributes for managed + // resources. + WriteOnlyAttributesAllowed bool + + // The raw config value provided by Terraform core + RawConfig cty.Value +} + +type ValidateResourceConfigResponse struct { + Diagnostics diag.Diagnostics +} + func (s *Schema) GoString() string { return fmt.Sprintf("*%#v", *s) } diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go new file mode 100644 index 00000000000..b729c81a2f3 --- /dev/null +++ b/helper/validation/write_only.go @@ -0,0 +1,143 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validation + +import ( + "context" + "fmt" + + "github.com/hashicorp/go-cty/cty" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +// PreferWriteOnlyAttribute is a ValidateResourceConfigFunc that returns a warning +// if the Terraform client supports write-only attributes and the old attribute +// has a value instead of the write-only attribute. +func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttribute cty.Path) schema.ValidateResourceConfigFunc { + return func(ctx context.Context, req schema.ValidateResourceConfigRequest, resp *schema.ValidateResourceConfigResponse) { + if !req.WriteOnlyAttributesAllowed { + return + } + + // Apply all but the last step to retrieve the attribute name + // for any diags that we return. + oldLastStepVal, oldLastStep, err := oldAttribute.LastStep(req.RawConfig) + if err != nil { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: fmt.Sprintf("Encountered an error when applying the specified oldAttribute path, "+ + "original error: %s", err), + AttributePath: oldAttribute, + }, + } + return + } + + // Only attribute steps have a Name field + oldAttributeStep, ok := oldLastStep.(cty.GetAttrStep) + if !ok { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "The specified oldAttribute path must point to an attribute", + AttributePath: oldAttribute, + }, + } + return + } + + oldAttributeConfigVal, err := oldAttributeStep.Apply(oldLastStepVal) + if err != nil { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: fmt.Sprintf("Encountered an error when applying the specified oldAttribute path, "+ + "original error: %s", err), + AttributePath: oldAttribute, + }, + } + return + } + + writeOnlyLastStepVal, writeOnlyLastStep, err := writeOnlyAttribute.LastStep(req.RawConfig) + if err != nil { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: fmt.Sprintf("Encountered an error when applying the specified writeOnlyAttribute path, "+ + "original error: %s", err), + AttributePath: writeOnlyAttribute, + }, + } + return + } + + // Only attribute steps have a Name field + writeOnlyAttributeStep, ok := writeOnlyLastStep.(cty.GetAttrStep) + if !ok { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: "The specified writeOnlyAttribute path must point to an attribute", + AttributePath: writeOnlyAttribute, + }, + } + return + } + + writeOnlyAttributeConfigVal, err := writeOnlyAttributeStep.Apply(writeOnlyLastStepVal) + if err != nil { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: fmt.Sprintf("Encountered an error when applying the specified writeOnlyAttribute path, "+ + "original error: %s", err), + AttributePath: writeOnlyAttribute, + }, + } + return + } + + //oldAttributeConfigVal, err := cty.Transform(req.RawConfig, func(path cty.Path, val cty.Value) (cty.Value, error) { + // if path.Equals(oldAttribute) { + // oldAttributeConfig := req.RawConfig.GetAttr(oldAttributeName) + // println(oldAttributeConfig.IsKnown()) + // return val, nil + // } + // + // // nothing to do if we already have a value + // if !val.IsNull() { + // return val, nil + // } + // + // return val, nil + //}) + //// We shouldn't encounter any errors here, but handling them just in case. + //if err != nil { + // resp.Diagnostics = diag.FromErr(err) + // return + //} + + if !oldAttributeConfigVal.IsNull() && writeOnlyAttributeConfigVal.IsNull() { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: fmt.Sprintf("The attribute %s has a WriteOnly version %s available. "+ + "Use the WriteOnly version of the attribute when possible.", oldAttributeStep.Name, writeOnlyAttributeStep.Name), + AttributePath: oldAttribute, + }, + } + } + } +} diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go new file mode 100644 index 00000000000..69e6edb8985 --- /dev/null +++ b/helper/validation/write_only_test.go @@ -0,0 +1,326 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validation + +import ( + "context" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-cty/cty" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" +) + +func TestPreferWriteOnlyAttribute(t *testing.T) { + cases := map[string]struct { + oldAttributePath cty.Path + writeOnlyAttributePath cty.Path + validateConfigReq schema.ValidateResourceConfigRequest + expectedDiags diag.Diagnostics + }{ + "writeOnlyAttributeAllowed unset returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: false, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NullVal(cty.Number), + }), + }, + }, + "oldAttribute and writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + }, + "writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.Number), + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + }, + "oldAttributePath pointing to missing attribute returns error diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "Encountered an error when applying the specified oldAttribute path, original error: object has no attribute \"oldAttribute\"", + AttributePath: cty.Path{cty.GetAttrStep{Name: "oldAttribute"}}, + }, + }, + }, + "writeOnlyAttributePath pointing to missing attribute returns error diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: "Encountered an error when applying the specified writeOnlyAttribute path, original error: object has no attribute \"writeOnlyAttribute\"", + AttributePath: cty.Path{cty.GetAttrStep{Name: "writeOnlyAttribute"}}, + }, + }, + }, + "oldAttributePath with empty path returns error diag": { + oldAttributePath: cty.Path{}, + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "The specified oldAttribute path must point to an attribute", + AttributePath: cty.Path{}, + }, + }, + }, + "writeOnlyAttributePath with empty path returns error diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.Path{}, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NumberIntVal(42), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttributePath", + Detail: "The specified writeOnlyAttribute path must point to an attribute", + AttributePath: cty.Path{}, + }, + }, + }, + "only oldAttribute set returns warning diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), + "writeOnlyAttribute": cty.NullVal(cty.Number), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{cty.GetAttrStep{Name: "oldAttribute"}}, + }, + }, + }, + "block: oldAttribute and writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }, + }, + "block: writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }, + }, + "block: only oldAttribute set returns warning diag": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "set nested block: oldAttribute and writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), + })}, + cty.IndexStep{Key: cty.StringVal("oldAttribute")}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.IndexStep{Key: cty.StringVal("writeOnlyAttribute")}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }), + }, + }, + "set nested block: writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }, + }, + "set nested block: only oldAttribute set returns warning diag": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + writeOnlyAttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "writeOnlyAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "config_block_attr": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + } + + for name, tc := range cases { + t.Run(name, func(t *testing.T) { + f := PreferWriteOnlyAttribute(tc.oldAttributePath, tc.writeOnlyAttributePath) + + actual := &schema.ValidateResourceConfigResponse{} + f(context.Background(), tc.validateConfigReq, actual) + + if len(actual.Diagnostics) == 0 && tc.expectedDiags == nil { + return + } + + if len(actual.Diagnostics) != 0 && tc.expectedDiags == nil { + t.Fatalf("expected no diagnostics but got %v", actual.Diagnostics) + } + + if diff := cmp.Diff(tc.expectedDiags, actual.Diagnostics, cmp.AllowUnexported(cty.GetAttrStep{})); diff != "" { + t.Errorf("Unexpected diagnostics (-wanted +got): %s", diff) + } + }) + } +} From 25c2a13a8cfa04aadd4cd61f699495695ea0a497 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 9 Sep 2024 19:02:14 -0400 Subject: [PATCH 09/58] Finish `PreferWriteOnlyAttribute()` validator implementation. --- helper/validation/path.go | 55 +++ helper/validation/path_test.go | 116 +++++ helper/validation/write_only.go | 153 ++---- helper/validation/write_only_test.go | 699 ++++++++++++++++++++++----- 4 files changed, 798 insertions(+), 225 deletions(-) create mode 100644 helper/validation/path.go create mode 100644 helper/validation/path_test.go diff --git a/helper/validation/path.go b/helper/validation/path.go new file mode 100644 index 00000000000..b17449a3d5a --- /dev/null +++ b/helper/validation/path.go @@ -0,0 +1,55 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validation + +import ( + "github.com/hashicorp/go-cty/cty" +) + +// PathEquals compares two Paths for equality. For cty.IndexStep, +// unknown key values are treated as an Any qualifier and will +// match any index step of the same type. +func PathEquals(p cty.Path, other cty.Path) bool { + if len(p) != len(other) { + return false + } + + for i := range p { + pv := p[i] + switch pv := pv.(type) { + case cty.GetAttrStep: + ov, ok := other[i].(cty.GetAttrStep) + if !ok || pv != ov { + return false + } + case cty.IndexStep: + ov, ok := other[i].(cty.IndexStep) + if !ok { + return false + } + + // Sets need special handling since their Type is the entire object + // with attributes. + if pv.Key.Type().IsObjectType() && ov.Key.Type().IsObjectType() { + if !pv.Key.IsKnown() || !ov.Key.IsKnown() { + break + } + } + if !pv.Key.Type().Equals(ov.Key.Type()) { + return false + } + + if pv.Key.IsKnown() && ov.Key.IsKnown() { + if !pv.Key.RawEquals(ov.Key) { + return false + } + } + default: + // Any invalid steps default to evaluating false. + return false + } + } + + return true +} diff --git a/helper/validation/path_test.go b/helper/validation/path_test.go new file mode 100644 index 00000000000..aabb2dc281a --- /dev/null +++ b/helper/validation/path_test.go @@ -0,0 +1,116 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package validation + +import ( + "testing" + + "github.com/hashicorp/go-cty/cty" +) + +func TestPathEquals(t *testing.T) { + tests := map[string]struct { + p cty.Path + other cty.Path + want bool + }{ + "null paths returns true": { + p: nil, + other: nil, + want: true, + }, + "empty paths returns true": { + p: cty.Path{}, + other: cty.Path{}, + want: true, + }, + "exact same path returns true": { + p: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + want: true, + }, + "path with unknown number index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + want: true, + }, + "other path with unknown number index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + want: true, + }, + "both paths with unknown number index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + want: true, + }, + "path with unknown string index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.StringVal("key")).GetAttr("nestedAttribute"), + want: true, + }, + "other path with unknown string index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.StringVal("key")).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + want: true, + }, + "both paths with unknown string index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + want: true, + }, + "path with unknown object index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Object(nil))).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.ObjectVal( + map[string]cty.Value{ + "oldAttribute": cty.StringVal("old"), + "writeOnlyAttribute": cty.StringVal("writeOnly"), + }, + )).GetAttr("nestedAttribute"), + want: true, + }, + "other path with unknown object index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.ObjectVal( + map[string]cty.Value{ + "oldAttribute": cty.StringVal("old"), + "writeOnlyAttribute": cty.StringVal("writeOnly"), + }, + )).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Object(nil))).GetAttr("nestedAttribute"), + want: true, + }, + "both paths with unknown object index returns true": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Object(nil))).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Object(nil))).GetAttr("nestedAttribute"), + want: true, + }, + "paths with unequal steps return false": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)), + other: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + want: false, + }, + "paths with mismatched attribute names return false": { + p: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("incorrect").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + want: false, + }, + "paths with mismatched unknown index types return false": { + p: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.Number)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + want: false, + }, + "other path with unknown index, different type return false": { + p: cty.GetAttrPath("attribute").Index(cty.NumberIntVal(1)).GetAttr("nestedAttribute"), + other: cty.GetAttrPath("attribute").Index(cty.UnknownVal(cty.String)).GetAttr("nestedAttribute"), + want: false, + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if got := PathEquals(tc.p, tc.other); got != tc.want { + t.Errorf("PathEquals() = %v, want %v", got, tc.want) + } + }) + } +} diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go index b729c81a2f3..1fa9636814f 100644 --- a/helper/validation/write_only.go +++ b/helper/validation/write_only.go @@ -14,130 +14,75 @@ import ( ) // PreferWriteOnlyAttribute is a ValidateResourceConfigFunc that returns a warning -// if the Terraform client supports write-only attributes and the old attribute -// has a value instead of the write-only attribute. -func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttribute cty.Path) schema.ValidateResourceConfigFunc { - return func(ctx context.Context, req schema.ValidateResourceConfigRequest, resp *schema.ValidateResourceConfigResponse) { +// if the Terraform client supports write-only attributes and the old attribute is +// not null. +// The last step in the path must be a cty.GetAttrStep{}. +// When creating a cty.IndexStep{} to into a nested attribute, use an unknown value +// of the index type to indicate any key value. +// For lists: cty.Index(cty.UnknownVal(cty.Number)), +// For maps: cty.Index(cty.UnknownVal(cty.String)), +// For sets: cty.Index(cty.UnknownVal(cty.Object(nil))), +func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName string) schema.ValidateResourceConfigFunc { + return func(ctx context.Context, req schema.ValidateResourceConfigFuncRequest, resp *schema.ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { return } - // Apply all but the last step to retrieve the attribute name - // for any diags that we return. - oldLastStepVal, oldLastStep, err := oldAttribute.LastStep(req.RawConfig) - if err != nil { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: fmt.Sprintf("Encountered an error when applying the specified oldAttribute path, "+ - "original error: %s", err), - AttributePath: oldAttribute, - }, - } - return - } + var oldAttrs []attribute - // Only attribute steps have a Name field - oldAttributeStep, ok := oldLastStep.(cty.GetAttrStep) - if !ok { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "The specified oldAttribute path must point to an attribute", - AttributePath: oldAttribute, - }, + err := cty.Walk(req.RawConfig, func(path cty.Path, value cty.Value) (bool, error) { + if PathEquals(path, oldAttribute) { + oldAttrs = append(oldAttrs, attribute{ + value: value, + path: path, + }) } - return - } - oldAttributeConfigVal, err := oldAttributeStep.Apply(oldLastStepVal) + return true, nil + }) if err != nil { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: fmt.Sprintf("Encountered an error when applying the specified oldAttribute path, "+ - "original error: %s", err), - AttributePath: oldAttribute, - }, - } return } - writeOnlyLastStepVal, writeOnlyLastStep, err := writeOnlyAttribute.LastStep(req.RawConfig) - if err != nil { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: fmt.Sprintf("Encountered an error when applying the specified writeOnlyAttribute path, "+ - "original error: %s", err), - AttributePath: writeOnlyAttribute, - }, - } - return - } + for _, attr := range oldAttrs { + attrPath := attr.path.Copy() - // Only attribute steps have a Name field - writeOnlyAttributeStep, ok := writeOnlyLastStep.(cty.GetAttrStep) - if !ok { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: "The specified writeOnlyAttribute path must point to an attribute", - AttributePath: writeOnlyAttribute, - }, - } - return - } + pathLen := len(attrPath) - writeOnlyAttributeConfigVal, err := writeOnlyAttributeStep.Apply(writeOnlyLastStepVal) - if err != nil { - resp.Diagnostics = diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: fmt.Sprintf("Encountered an error when applying the specified writeOnlyAttribute path, "+ - "original error: %s", err), - AttributePath: writeOnlyAttribute, - }, + if pathLen == 0 { + return } - return - } - //oldAttributeConfigVal, err := cty.Transform(req.RawConfig, func(path cty.Path, val cty.Value) (cty.Value, error) { - // if path.Equals(oldAttribute) { - // oldAttributeConfig := req.RawConfig.GetAttr(oldAttributeName) - // println(oldAttributeConfig.IsKnown()) - // return val, nil - // } - // - // // nothing to do if we already have a value - // if !val.IsNull() { - // return val, nil - // } - // - // return val, nil - //}) - //// We shouldn't encounter any errors here, but handling them just in case. - //if err != nil { - // resp.Diagnostics = diag.FromErr(err) - // return - //} + lastStep := attrPath[pathLen-1] + + // Only attribute steps have a Name field + attrStep, ok := lastStep.(cty.GetAttrStep) + if !ok { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "The specified oldAttribute path must point to an attribute.", + AttributePath: attrPath, + }, + } + return + } - if !oldAttributeConfigVal.IsNull() && writeOnlyAttributeConfigVal.IsNull() { - resp.Diagnostics = diag.Diagnostics{ - { + if !attr.value.IsNull() { + resp.Diagnostics = append(resp.Diagnostics, diag.Diagnostic{ Severity: diag.Warning, Summary: "Available Write-Only Attribute Alternative", Detail: fmt.Sprintf("The attribute %s has a WriteOnly version %s available. "+ - "Use the WriteOnly version of the attribute when possible.", oldAttributeStep.Name, writeOnlyAttributeStep.Name), - AttributePath: oldAttribute, - }, + "Use the WriteOnly version of the attribute when possible.", attrStep.Name, writeOnlyAttributeName), + AttributePath: attr.path, + }) } } } } + +type attribute struct { + value cty.Value + path cty.Path +} diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go index 69e6edb8985..d641c490ea0 100644 --- a/helper/validation/write_only_test.go +++ b/helper/validation/write_only_test.go @@ -16,15 +16,13 @@ import ( func TestPreferWriteOnlyAttribute(t *testing.T) { cases := map[string]struct { - oldAttributePath cty.Path - writeOnlyAttributePath cty.Path - validateConfigReq schema.ValidateResourceConfigRequest - expectedDiags diag.Diagnostics + oldAttributePath cty.Path + validateConfigReq schema.ValidateResourceConfigFuncRequest + expectedDiags diag.Diagnostics }{ - "writeOnlyAttributeAllowed unset returns no diags": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "writeOnlyAttributeAllowed set to false with oldAttribute set returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: false, RawConfig: cty.ObjectVal(map[string]cty.Value{ "oldAttribute": cty.NumberIntVal(42), @@ -32,106 +30,85 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }), }, }, - "oldAttribute and writeOnlyAttribute set returns no diags": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "invalid oldAttributePath returns error diag": { + oldAttributePath: cty.GetAttrPath("oldAttribute").Index(cty.UnknownVal(cty.String)), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NumberIntVal(42), - "writeOnlyAttribute": cty.NumberIntVal(42), + "oldAttribute": cty.ListVal([]cty.Value{ + cty.StringVal("val1"), + cty.StringVal("val2"), + }), + "writeOnlyAttribute": cty.NullVal(cty.Number), }), }, - }, - "writeOnlyAttribute set returns no diags": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ - WriteOnlyAttributesAllowed: true, - RawConfig: cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NullVal(cty.Number), - "writeOnlyAttribute": cty.NumberIntVal(42), - }), + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid oldAttributePath", + Detail: "The specified oldAttribute path must point to an attribute.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "oldAttribute"}, + cty.IndexStep{ + Key: cty.NumberIntVal(1), + }, + }, + }, }, }, - "oldAttributePath pointing to missing attribute returns error diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "oldAttribute and writeOnlyAttribute set returns warning diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NumberIntVal(42), "writeOnlyAttribute": cty.NumberIntVal(42), }), }, expectedDiags: diag.Diagnostics{ { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "Encountered an error when applying the specified oldAttribute path, original error: object has no attribute \"oldAttribute\"", + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", AttributePath: cty.Path{cty.GetAttrStep{Name: "oldAttribute"}}, }, }, }, - "writeOnlyAttributePath pointing to missing attribute returns error diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NumberIntVal(42), + "oldAttribute": cty.NullVal(cty.Number), + "writeOnlyAttribute": cty.NumberIntVal(42), }), }, - expectedDiags: diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: "Encountered an error when applying the specified writeOnlyAttribute path, original error: object has no attribute \"writeOnlyAttribute\"", - AttributePath: cty.Path{cty.GetAttrStep{Name: "writeOnlyAttribute"}}, - }, - }, }, - "oldAttributePath with empty path returns error diag": { - oldAttributePath: cty.Path{}, - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + "oldAttributePath pointing to missing attribute returns no diags": { + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NumberIntVal(42), "writeOnlyAttribute": cty.NumberIntVal(42), }), }, - expectedDiags: diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "The specified oldAttribute path must point to an attribute", - AttributePath: cty.Path{}, - }, - }, + expectedDiags: nil, }, - "writeOnlyAttributePath with empty path returns error diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.Path{}, - validateConfigReq: schema.ValidateResourceConfigRequest{ + "oldAttributePath with empty path returns no diags": { + oldAttributePath: cty.Path{}, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "oldAttribute": cty.NumberIntVal(42), "writeOnlyAttribute": cty.NumberIntVal(42), }), }, - expectedDiags: diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Invalid writeOnlyAttributePath", - Detail: "The specified writeOnlyAttribute path must point to an attribute", - AttributePath: cty.Path{}, - }, - }, + expectedDiags: nil, }, "only oldAttribute set returns warning diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute"), - writeOnlyAttributePath: cty.GetAttrPath("writeOnlyAttribute"), - validateConfigReq: schema.ValidateResourceConfigRequest{ + oldAttributePath: cty.GetAttrPath("oldAttribute"), + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "oldAttribute": cty.NumberIntVal(42), @@ -148,16 +125,12 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, }, }, - "block: oldAttribute and writeOnlyAttribute set returns no diags": { + "block: oldAttribute and writeOnlyAttribute set returns warning diag": { oldAttributePath: cty.Path{ cty.GetAttrStep{Name: "config_block_attr"}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, - }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -167,17 +140,25 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }), }), }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, }, "block: writeOnlyAttribute set returns no diags": { oldAttributePath: cty.Path{ cty.GetAttrStep{Name: "config_block_attr"}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, - }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -193,11 +174,7 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { cty.GetAttrStep{Name: "config_block_attr"}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, - }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -220,23 +197,151 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, }, }, - "set nested block: oldAttribute and writeOnlyAttribute set returns no diags": { + "list nested block: oldAttribute and writeOnlyAttribute set returns warning diag": { oldAttributePath: cty.Path{ - cty.GetAttrStep{Name: "set_nested_block"}, - cty.IndexStep{Key: cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.StringVal("value"), - "writeOnlyAttribute": cty.StringVal("value"), + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Number)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), }), - })}, - cty.IndexStep{Key: cty.StringVal("oldAttribute")}, + }), }, - writeOnlyAttributePath: cty.Path{ + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "list nested block: writeOnlyAttribute set returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Number)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: nil, + }, + "list nested block: only oldAttribute set returns warning diag": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Number)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "list nested block: multiple oldAttribute set returns multiple warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Number)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "list_nested_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_nested_block"}, + cty.IndexStep{Key: cty.NumberIntVal(2)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "set nested block: oldAttribute and writeOnlyAttribute set returns warning diags": { + oldAttributePath: cty.Path{ cty.GetAttrStep{Name: "set_nested_block"}, - cty.IndexStep{Key: cty.NumberIntVal(0)}, - cty.IndexStep{Key: cty.StringVal("writeOnlyAttribute")}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object( + map[string]cty.Type{ + "oldAttribute": cty.String, + "writeOnlyAttribute": cty.String, + }, + ))}, + cty.GetAttrStep{Name: "oldAttribute"}, }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -248,43 +353,106 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }), }), }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, }, "set nested block: writeOnlyAttribute set returns no diags": { oldAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object( + map[string]cty.Type{ + "oldAttribute": cty.String, + }, + ))}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, - }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), - "config_block_attr": cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.NullVal(cty.String), - "writeOnlyAttribute": cty.StringVal("value"), + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.StringVal("value"), + }), }), }), }, + expectedDiags: nil, }, "set nested block: only oldAttribute set returns warning diag": { oldAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object( + map[string]cty.Type{ + "oldAttribute": cty.String, + }, + ))}, cty.GetAttrStep{Name: "oldAttribute"}, }, - writeOnlyAttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, - cty.GetAttrStep{Name: "writeOnlyAttribute"}, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "set nested block: multiple oldAttribute set returns multiple warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object(nil))}, + cty.GetAttrStep{Name: "oldAttribute"}, }, - validateConfigReq: schema.ValidateResourceConfigRequest{ + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), - "config_block_attr": cty.ObjectVal(map[string]cty.Value{ - "oldAttribute": cty.StringVal("value"), - "writeOnlyAttribute": cty.NullVal(cty.String), + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), }), }), }, @@ -295,7 +463,286 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + "Use the WriteOnly version of the attribute when possible.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "config_block_attr"}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "map nested block: oldAttribute and writeOnlyAttribute map returns warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "map nested block: writeOnlyAttribute map returns no diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "writeOnlyAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: nil, + }, + "map nested block: only oldAttribute map returns warning diag": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "map nested block: multiple oldAttribute map returns multiple warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + "key2": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + "key3": cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key3")}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + }, + }, + "map nested set nested block: multiple oldAttribute map returns multiple warning diags": { + oldAttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.String)}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.UnknownVal(cty.Object(nil))}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + validateConfigReq: schema.ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: true, + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "key1": cty.ObjectVal(map[string]cty.Value{ + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + "string_nested_attribute": cty.NullVal(cty.String), + }), + "key2": cty.ObjectVal(map[string]cty.Value{ + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + }), + "string_nested_attribute": cty.StringVal("value1"), + }), + "key3": cty.ObjectVal(map[string]cty.Value{ + "set_nested_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.NullVal(cty.String), + "writeOnlyAttribute": cty.StringVal("value2"), + }), + cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }), + "string_nested_attribute": cty.StringVal("value1"), + }), + }), + }), + }, + expectedDiags: diag.Diagnostics{ + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key1")}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key3")}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value1"), + "writeOnlyAttribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "oldAttribute"}, + }, + }, + { + Severity: diag.Warning, + Summary: "Available Write-Only Attribute Alternative", + Detail: "The attribute oldAttribute has a WriteOnly version writeOnlyAttribute available. " + + "Use the WriteOnly version of the attribute when possible.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_nested_block"}, + cty.IndexStep{Key: cty.StringVal("key3")}, + cty.GetAttrStep{Name: "set_nested_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "oldAttribute": cty.StringVal("value3"), + "writeOnlyAttribute": cty.NullVal(cty.String), + }), + }, cty.GetAttrStep{Name: "oldAttribute"}, }, }, @@ -305,9 +752,9 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - f := PreferWriteOnlyAttribute(tc.oldAttributePath, tc.writeOnlyAttributePath) + f := PreferWriteOnlyAttribute(tc.oldAttributePath, "writeOnlyAttribute") - actual := &schema.ValidateResourceConfigResponse{} + actual := &schema.ValidateResourceConfigFuncResponse{} f(context.Background(), tc.validateConfigReq, actual) if len(actual.Diagnostics) == 0 && tc.expectedDiags == nil { @@ -318,9 +765,19 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { t.Fatalf("expected no diagnostics but got %v", actual.Diagnostics) } - if diff := cmp.Diff(tc.expectedDiags, actual.Diagnostics, cmp.AllowUnexported(cty.GetAttrStep{})); diff != "" { + if diff := cmp.Diff(tc.expectedDiags, actual.Diagnostics, + cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), + cmp.Comparer(indexStepComparer), + ); diff != "" { t.Errorf("Unexpected diagnostics (-wanted +got): %s", diff) } }) } } + +func indexStepComparer(step cty.IndexStep, other cty.IndexStep) bool { + if !step.Key.RawEquals(other.Key) { + return false + } + return true +} From 2f917f8b0d8e589d585ce46e25dbee923bf49b5f Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 9 Sep 2024 19:03:54 -0400 Subject: [PATCH 10/58] Move `schema.ValidateResourceConfigFuncs` to `schema.Resource` and implement validation in `ValidateResourceTypeConfig()` RPC --- helper/schema/grpc_provider.go | 23 +++ helper/schema/grpc_provider_test.go | 221 +++++++++++++++++++++++++++ helper/schema/resource.go | 29 ++++ helper/schema/schema.go | 25 --- helper/validation/write_only_test.go | 9 +- 5 files changed, 276 insertions(+), 31 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 2f82c6c75b5..34c67bb4379 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -286,6 +286,29 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock)) } + r := s.provider.ResourcesMap[req.TypeName] + + // Calling ValidateResourceConfigFunc here since provider.ValidateResource() + // is a public function, so we can't change its signature. + if r.ValidateResourceConfigFuncs != nil { + writeOnlyAllowed := false + + if req.ClientCapabilities != nil { + writeOnlyAllowed = req.ClientCapabilities.WriteOnlyAttributesAllowed + } + + validateReq := ValidateResourceConfigFuncRequest{ + WriteOnlyAttributesAllowed: writeOnlyAllowed, + RawConfig: configVal, + } + + for _, validateFunc := range r.ValidateResourceConfigFuncs { + validateResp := &ValidateResourceConfigFuncResponse{} + validateFunc(ctx, validateReq, validateResp) + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateResp.Diagnostics) + } + } + config := terraform.NewResourceConfigShimmed(configVal, schemaBlock) logging.HelperSchemaTrace(ctx, "Calling downstream") diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 3ee32217e49..1c006084379 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3714,6 +3714,227 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { }, }, }, + "Server with ValidateResourceConfigFunc: WriteOnlyAttributesAllowed true returns diags": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + if req.WriteOnlyAttributesAllowed { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + if req.WriteOnlyAttributesAllowed { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + ClientCapabilities: &tfprotov5.ValidateResourceTypeConfigClientCapabilities{ + WriteOnlyAttributesAllowed: true, + }, + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + }, + }, + }, + "Server with ValidateResourceConfigFunc: WriteOnlyAttributesAllowed false returns diags": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + if !req.WriteOnlyAttributesAllowed { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + if !req.WriteOnlyAttributesAllowed { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + }, + }, + }, + "Server with ValidateResourceConfigFunc: equal config value returns diags": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test_resource": { + ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + equals := req.RawConfig.Equals(cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + })) + if equals.True() { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + equals := req.RawConfig.Equals(cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + })) + if equals.True() { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "ValidateResourceConfigFunc Error", + }, + } + } + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeInt, + Optional: true, + }, + "bar": { + Type: TypeInt, + Optional: true, + }, + }, + }, + }, + }), + request: &tfprotov5.ValidateResourceTypeConfigRequest{ + TypeName: "test_resource", + Config: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "foo": cty.Number, + "bar": cty.Number, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.NullVal(cty.String), + "foo": cty.NumberIntVal(2), + "bar": cty.NumberIntVal(2), + }), + ), + }, + }, + expected: &tfprotov5.ValidateResourceTypeConfigResponse{ + Diagnostics: []*tfprotov5.Diagnostic{ + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + { + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "ValidateResourceConfigFunc Error", + }, + }, + }, + }, } for name, testCase := range testCases { diff --git a/helper/schema/resource.go b/helper/schema/resource.go index 1c944c9b481..dd52d1ca777 100644 --- a/helper/schema/resource.go +++ b/helper/schema/resource.go @@ -644,6 +644,16 @@ type Resource struct { // ResourceBehavior is used to control SDK-specific logic when // interacting with this resource. ResourceBehavior ResourceBehavior + + // ValidateResourceConfigFuncs allows functions to define arbitrary validation + // logic during the ValidateResourceTypeConfig RPC. ValidateResourceConfigFunc receives + // the client capabilities from the ValidateResourceTypeConfig RPC and the raw cty + // config value for the entire resource before it is shimmed, and it can return error + // diagnostics based on the inspection of those values. + // + // ValidateResourceConfigFuncs is only valid for Managed Resource types and will not be + // called for Data Resource or Block types. + ValidateResourceConfigFuncs []ValidateResourceConfigFunc } // ResourceBehavior controls SDK-specific logic when interacting @@ -670,6 +680,25 @@ type ProviderDeferredBehavior struct { EnablePlanModification bool } +// ValidateResourceConfigFunc is a function used to validate the raw resource config +// and has Diagnostic support. it is only valid for Managed Resource types and will not be +// called for Data Resource or Block types. +type ValidateResourceConfigFunc func(context.Context, ValidateResourceConfigFuncRequest, *ValidateResourceConfigFuncResponse) + +type ValidateResourceConfigFuncRequest struct { + // WriteOnlyAttributesAllowed indicates that the Terraform client + // initiating the request supports write-only attributes for managed + // resources. + WriteOnlyAttributesAllowed bool + + // The raw config value provided by Terraform core + RawConfig cty.Value +} + +type ValidateResourceConfigFuncResponse struct { + Diagnostics diag.Diagnostics +} + // SchemaMap returns the schema information for this Resource whether it is // defined via the SchemaFunc field or Schema field. The SchemaFunc field, if // defined, takes precedence over the Schema field. diff --git a/helper/schema/schema.go b/helper/schema/schema.go index fe29e42e6c3..8dde0469029 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -371,13 +371,6 @@ type Schema struct { // AttributePath: append(path, cty.IndexStep{Key: cty.StringVal("key_name")}) ValidateDiagFunc SchemaValidateDiagFunc - // ValidateResourceConfig allows a function to define arbitrary validation - // logic during the ValidateResourceTypeConfig RPC. ValidateResourceConfigFunc receives - // the client capabilities from the ValidateResourceTypeConfig RPC and the raw cty - // config value for the entire resource before it is shimmed, and it can return error - // diagnostics based on the inspection of those values. - ValidateResourceConfig ValidateResourceConfigFunc - // Sensitive ensures that the attribute's value does not get displayed in // the Terraform user interface output. It should be used for password or // other values which should be hidden. @@ -483,24 +476,6 @@ type SchemaValidateFunc func(interface{}, string) ([]string, []error) // schema and has Diagnostic support. type SchemaValidateDiagFunc func(interface{}, cty.Path) diag.Diagnostics -// ValidateResourceConfigFunc is a function used to validate the raw resource config -// and has Diagnostic support. -type ValidateResourceConfigFunc func(context.Context, ValidateResourceConfigRequest, *ValidateResourceConfigResponse) - -type ValidateResourceConfigRequest struct { - // WriteOnlyAttributesAllowed indicates that the Terraform client - // initiating the request supports write-only attributes for managed - // resources. - WriteOnlyAttributesAllowed bool - - // The raw config value provided by Terraform core - RawConfig cty.Value -} - -type ValidateResourceConfigResponse struct { - Diagnostics diag.Diagnostics -} - func (s *Schema) GoString() string { return fmt.Sprintf("*%#v", *s) } diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go index d641c490ea0..8adf6d70f4e 100644 --- a/helper/validation/write_only_test.go +++ b/helper/validation/write_only_test.go @@ -31,7 +31,7 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, }, "invalid oldAttributePath returns error diag": { - oldAttributePath: cty.GetAttrPath("oldAttribute").Index(cty.UnknownVal(cty.String)), + oldAttributePath: cty.GetAttrPath("oldAttribute").Index(cty.UnknownVal(cty.Number)), validateConfigReq: schema.ValidateResourceConfigFuncRequest{ WriteOnlyAttributesAllowed: true, RawConfig: cty.ObjectVal(map[string]cty.Value{ @@ -50,7 +50,7 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { AttributePath: cty.Path{ cty.GetAttrStep{Name: "oldAttribute"}, cty.IndexStep{ - Key: cty.NumberIntVal(1), + Key: cty.NumberIntVal(0), }, }, }, @@ -776,8 +776,5 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { } func indexStepComparer(step cty.IndexStep, other cty.IndexStep) bool { - if !step.Key.RawEquals(other.Key) { - return false - } - return true + return step.Key.RawEquals(other.Key) } From cab2a27fd32851686a4e8de9f50a73e9f777ba1e Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 10 Sep 2024 13:23:40 -0400 Subject: [PATCH 11/58] Add automatic state handling for writeOnly attributes --- helper/schema/grpc_provider.go | 102 ++++++++ helper/schema/grpc_provider_test.go | 385 ++++++++++++++++++++++++++++ 2 files changed, 487 insertions(+) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 34c67bb4379..8bc0190e64a 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -1219,6 +1219,8 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro newStateVal = copyTimeoutValues(newStateVal, plannedStateVal) + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) @@ -2055,3 +2057,103 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con return diags } + +// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null +// values that are writeOnly to null. +func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { + if !val.IsKnown() || val.IsNull() { + return val + } + + valMap := val.AsValueMap() + newVals := make(map[string]cty.Value) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && !v.IsNull() { + newVals[name] = cty.NullVal(attr.Type) + continue + } + + newVals[name] = v + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + newVals[name] = blockVal + continue + } + + blockValType := blockVal.Type() + blockElementType := blockS.Block.ImpliedType() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) + + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + newListVals := make([]cty.Value, 0, len(listVals)) + + for _, v := range listVals { + newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) + } + + switch { + case blockValType.IsSetType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.SetValEmpty(blockElementType) + default: + newVals[name] = cty.SetVal(newListVals) + } + case blockValType.IsListType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.ListValEmpty(blockElementType) + default: + newVals[name] = cty.ListVal(newListVals) + } + case blockValType.IsTupleType(): + newVals[name] = cty.TupleVal(newListVals) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + newMapVals := make(map[string]cty.Value) + + for k, v := range mapVals { + newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) + } + + switch { + case blockValType.IsMapType(): + switch len(newMapVals) { + case 0: + newVals[name] = cty.MapValEmpty(blockElementType) + default: + newVals[name] = cty.MapVal(newMapVals) + } + case blockValType.IsObjectType(): + if len(newMapVals) == 0 { + // We need to populate empty values to make a valid object. + for attr, ty := range blockElementType.AttributeTypes() { + newMapVals[attr] = cty.NullVal(ty) + } + } + newVals[name] = cty.ObjectVal(newMapVals) + } + + default: + panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) + } + } + + return cty.ObjectVal(newVals) +} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 1c006084379..6e357770b92 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -8226,6 +8226,391 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { } } +func Test_setWriteOnlyNullValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected cty.Value + }{ + "Empty returns no empty object": { + &configschema.Block{}, + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }, + "Top level attributes and block: write only attributes with values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.StringVal("blep"), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + "biz": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.NullVal(cty.String), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + "biz": cty.StringVal("boop"), + }), + }), + }, + "Top level attributes and block: all null values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + }, + "Set nested block: write only Nested Attribute": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("beep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + }), + }), + }), + }, + "Nested single block: write only nested attribute": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + }), + }, + "Map nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.StringVal("boop2"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "List nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("beep"), + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.StringVal("blep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("blep"), + }), + }), + }), + }, + "Set nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "Set nested Map block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + } { + t.Run(n, func(t *testing.T) { + got := setWriteOnlyNullValues(tc.Val, tc.Schema) + + if !got.RawEquals(tc.Expected) { + t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) + } + }) + } +} + func mustMsgpackMarshal(ty cty.Type, val cty.Value) []byte { result, err := msgpack.Marshal(val, ty) From 6cc6811008fc0f2f559524106e01533d7f77458e Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 16 Sep 2024 15:37:43 -0400 Subject: [PATCH 12/58] Apply suggestions from code review Co-authored-by: Austin Valle --- helper/schema/grpc_provider.go | 6 +++--- helper/schema/schema.go | 3 +-- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 8bc0190e64a..436bfafe9fe 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -288,8 +288,8 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req r := s.provider.ResourcesMap[req.TypeName] - // Calling ValidateResourceConfigFunc here since provider.ValidateResource() - // is a public function, so we can't change its signature. + // Calling all ValidateResourceConfigFunc here since they validate on the raw go-cty config value + // and were introduced after the public provider.ValidateResource method. if r.ValidateResourceConfigFuncs != nil { writeOnlyAllowed := false @@ -1956,7 +1956,7 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs if attr.WriteOnly && !v.IsNull() { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), }) } diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 8dde0469029..ce58deef24d 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -2380,8 +2380,7 @@ func (m schemaMap) validateType( return diags } -// hasWriteOnly returns true if the schemaMap contains any -// WriteOnly attributes are set. +// hasWriteOnly returns true if the schemaMap contains any WriteOnly attributes. func (m schemaMap) hasWriteOnly() bool { for _, v := range m { if v.WriteOnly { From 994bc66234d1cda000c17c3aeab651ac430d8fad Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Sep 2024 13:16:28 -0400 Subject: [PATCH 13/58] Wrap `setWriteOnlyNullValues` call in client capabilities check --- helper/schema/grpc_provider.go | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 436bfafe9fe..3f4041becb1 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -1219,7 +1219,9 @@ func (s *GRPCProviderServer) ApplyResourceChange(ctx context.Context, req *tfpro newStateVal = copyTimeoutValues(newStateVal, plannedStateVal) - newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + if req.ClientCapabilities != nil && req.ClientCapabilities.WriteOnlyAttributesAllowed { + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + } newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil { From ff70638c313a10b43ccbd2ceca30c579ca90ad03 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Sep 2024 13:17:12 -0400 Subject: [PATCH 14/58] Refactor tests to match diag summary changes --- helper/schema/grpc_provider_test.go | 32 ++++++++++++++--------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 6e357770b92..c041a57b675 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3579,7 +3579,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", }, }, @@ -3625,12 +3625,12 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, }, @@ -3703,12 +3703,12 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\"", }, }, @@ -7579,12 +7579,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", }, { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", }, }, @@ -7620,7 +7620,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, }, @@ -7662,7 +7662,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", }, }, @@ -7702,12 +7702,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, }, @@ -7749,12 +7749,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", }, }, @@ -7796,12 +7796,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", }, { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", }, }, @@ -7839,7 +7839,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attributes not Allowed", + Summary: "WriteOnly Attribute Not Allowed", Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", }, }, From c5c870d9a9daba49f13294260c82e07fffbba806 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Sep 2024 13:24:51 -0400 Subject: [PATCH 15/58] Move write-only helper functions and tests to their own files. --- helper/schema/grpc_provider.go | 219 ------ helper/schema/grpc_provider_test.go | 1123 -------------------------- helper/schema/write_only.go | 228 ++++++ helper/schema/write_only_test.go | 1133 +++++++++++++++++++++++++++ 4 files changed, 1361 insertions(+), 1342 deletions(-) create mode 100644 helper/schema/write_only.go create mode 100644 helper/schema/write_only_test.go diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 3f4041becb1..13fd33b9d34 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -17,7 +17,6 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" - "github.com/hashicorp/terraform-plugin-sdk/v2/diag" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/hcl2shim" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" @@ -1941,221 +1940,3 @@ func configureDeferralAllowed(in *tfprotov5.ConfigureProviderClientCapabilities) return in.DeferralAllowed } - -// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an -// error diagnostic for each non-null writeOnly attribute value. -func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { - if !val.IsKnown() || val.IsNull() { - return diag.Diagnostics{} - } - - valMap := val.AsValueMap() - diags := make([]diag.Diagnostic, 0) - - for name, attr := range schema.Attributes { - v := valMap[name] - - if attr.WriteOnly && !v.IsNull() { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), - }) - } - } - - for name, blockS := range schema.BlockTypes { - blockVal := valMap[name] - if blockVal.IsNull() || !blockVal.IsKnown() { - continue - } - - blockValType := blockVal.Type() - - // This switches on the value type here, so we can correctly switch - // between Tuples/Lists and Maps/Objects. - switch { - case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: - // NestingSingle is the only exception here, where we treat the - // block directly as an object - diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): - listVals := blockVal.AsValueSlice() - - for _, v := range listVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) - } - - case blockValType.IsMapType(), blockValType.IsObjectType(): - mapVals := blockVal.AsValueMap() - - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) - } - - default: - panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) - } - } - - return diags -} - -// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an -// error diagnostic for every WriteOnly + Required attribute null value. -func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { - if !val.IsKnown() || val.IsNull() { - return diag.Diagnostics{} - } - - valMap := val.AsValueMap() - diags := make([]diag.Diagnostic, 0) - - for name, attr := range schema.Attributes { - v := valMap[name] - - if attr.WriteOnly && attr.Required && v.IsNull() { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), - }) - } - } - - for name, blockS := range schema.BlockTypes { - blockVal := valMap[name] - if blockVal.IsNull() || !blockVal.IsKnown() { - continue - } - - blockValType := blockVal.Type() - - // This switches on the value type here, so we can correctly switch - // between Tuples/Lists and Maps/Objects. - switch { - case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: - // NestingSingle is the only exception here, where we treat the - // block directly as an object - diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): - listVals := blockVal.AsValueSlice() - - for _, v := range listVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) - } - - case blockValType.IsMapType(), blockValType.IsObjectType(): - mapVals := blockVal.AsValueMap() - - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) - } - - default: - panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) - } - } - - return diags -} - -// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null -// values that are writeOnly to null. -func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { - if !val.IsKnown() || val.IsNull() { - return val - } - - valMap := val.AsValueMap() - newVals := make(map[string]cty.Value) - - for name, attr := range schema.Attributes { - v := valMap[name] - - if attr.WriteOnly && !v.IsNull() { - newVals[name] = cty.NullVal(attr.Type) - continue - } - - newVals[name] = v - } - - for name, blockS := range schema.BlockTypes { - blockVal := valMap[name] - if blockVal.IsNull() || !blockVal.IsKnown() { - newVals[name] = blockVal - continue - } - - blockValType := blockVal.Type() - blockElementType := blockS.Block.ImpliedType() - - // This switches on the value type here, so we can correctly switch - // between Tuples/Lists and Maps/Objects. - switch { - case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: - // NestingSingle is the only exception here, where we treat the - // block directly as an object - newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) - - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): - listVals := blockVal.AsValueSlice() - newListVals := make([]cty.Value, 0, len(listVals)) - - for _, v := range listVals { - newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) - } - - switch { - case blockValType.IsSetType(): - switch len(newListVals) { - case 0: - newVals[name] = cty.SetValEmpty(blockElementType) - default: - newVals[name] = cty.SetVal(newListVals) - } - case blockValType.IsListType(): - switch len(newListVals) { - case 0: - newVals[name] = cty.ListValEmpty(blockElementType) - default: - newVals[name] = cty.ListVal(newListVals) - } - case blockValType.IsTupleType(): - newVals[name] = cty.TupleVal(newListVals) - } - - case blockValType.IsMapType(), blockValType.IsObjectType(): - mapVals := blockVal.AsValueMap() - newMapVals := make(map[string]cty.Value) - - for k, v := range mapVals { - newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) - } - - switch { - case blockValType.IsMapType(): - switch len(newMapVals) { - case 0: - newVals[name] = cty.MapValEmpty(blockElementType) - default: - newVals[name] = cty.MapVal(newMapVals) - } - case blockValType.IsObjectType(): - if len(newMapVals) == 0 { - // We need to populate empty values to make a valid object. - for attr, ty := range blockElementType.AttributeTypes() { - newMapVals[attr] = cty.NullVal(ty) - } - } - newVals[name] = cty.ObjectVal(newMapVals) - } - - default: - panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) - } - } - - return cty.ObjectVal(newVals) -} diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index c041a57b675..f7b04a42225 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -21,7 +21,6 @@ import ( "github.com/hashicorp/terraform-plugin-go/tftypes" "github.com/hashicorp/terraform-plugin-sdk/v2/diag" - "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/plugin/convert" "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" ) @@ -7489,1128 +7488,6 @@ func Test_pathToAttributePath_noSteps(t *testing.T) { } } -func Test_validateWriteOnlyNullValues(t *testing.T) { - for n, tc := range map[string]struct { - Schema *configschema.Block - Val cty.Value - Expected diag.Diagnostics - }{ - "Empty returns no diags": { - &configschema.Block{}, - cty.EmptyObjectVal, - diag.Diagnostics{}, - }, - "All null values return no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - diag.Diagnostics{}, - }, - "Set nested block WriteOnly attribute with value returns diag": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("foo_val"), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", - }, - }, - }, - "Nested single block, WriteOnly attribute with value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("beep"), - "baz": cty.StringVal("boop"), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, - }, - "Map nested block, WriteOnly attribute with value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, - }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, - }, - "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, - }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.StringVal("boop2"), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, - }, - "List nested block, WriteOnly attribute with dynamic value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.DynamicPseudoType, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.TupleVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NumberIntVal(8), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, - }, - } { - t.Run(n, func(t *testing.T) { - got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) - - if diff := cmp.Diff(got, tc.Expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - -func Test_validateWriteOnlyRequiredValues(t *testing.T) { - for n, tc := range map[string]struct { - Schema *configschema.Block - Val cty.Value - Expected diag.Diagnostics - }{ - "Empty returns no diags": { - &configschema.Block{}, - cty.EmptyObjectVal, - diag.Diagnostics{}, - }, - "All Required + WriteOnly with values return no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.StringVal("blep"), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - "biz": cty.StringVal("boop"), - }), - }), - diag.Diagnostics{}, - }, - "All Optional + WriteOnly with null values return no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - diag.Diagnostics{}, - }, - "Set nested block Required + WriteOnly attribute with null return diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.NullVal(cty.String), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"foo\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"boz\"", - }, - }, - }, - "Nested single block, Required + WriteOnly attribute with null returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("boop"), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", - }, - }, - }, - "Map nested block, Required + WriteOnly attribute with null value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - }, - }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("blep"), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", - }, - }, - }, - "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - }, - }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", - }, - }, - }, - } { - t.Run(n, func(t *testing.T) { - got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) - - if diff := cmp.Diff(got, tc.Expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - -func Test_setWriteOnlyNullValues(t *testing.T) { - for n, tc := range map[string]struct { - Schema *configschema.Block - Val cty.Value - Expected cty.Value - }{ - "Empty returns no empty object": { - &configschema.Block{}, - cty.EmptyObjectVal, - cty.EmptyObjectVal, - }, - "Top level attributes and block: write only attributes with values": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - }, - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Required: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.StringVal("blep"), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - "biz": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.NullVal(cty.String), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), - "biz": cty.StringVal("boop"), - }), - }), - }, - "Top level attributes and block: all null values": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - }, - "Set nested block: write only Nested Attribute": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("beep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), - }), - }), - }), - }, - "Nested single block: write only nested attribute": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - }), - }, - "Map nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.StringVal("boop2"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - }, - "List nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - Computed: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("beep"), - "baz": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.StringVal("blep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("blep"), - }), - }), - }), - }, - "Set nested block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - }), - }), - }, - "Set nested Map block: multiple write only nested attributes": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - }, - } { - t.Run(n, func(t *testing.T) { - got := setWriteOnlyNullValues(tc.Val, tc.Schema) - - if !got.RawEquals(tc.Expected) { - t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) - } - }) - } -} - func mustMsgpackMarshal(ty cty.Type, val cty.Value) []byte { result, err := msgpack.Marshal(val, ty) diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go new file mode 100644 index 00000000000..db62f33f4d1 --- /dev/null +++ b/helper/schema/write_only.go @@ -0,0 +1,228 @@ +package schema + +import ( + "fmt" + + "github.com/hashicorp/go-cty/cty" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" +) + +// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for each non-null writeOnly attribute value. +func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { + if !val.IsKnown() || val.IsNull() { + return diag.Diagnostics{} + } + + valMap := val.AsValueMap() + diags := make([]diag.Diagnostic, 0) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && !v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), + }) + } + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + continue + } + + blockValType := blockVal.Type() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + + for _, v := range listVals { + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + } + + default: + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + } + } + + return diags +} + +// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for every WriteOnly + Required attribute null value. +func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { + if !val.IsKnown() || val.IsNull() { + return diag.Diagnostics{} + } + + valMap := val.AsValueMap() + diags := make([]diag.Diagnostic, 0) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && attr.Required && v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + }) + } + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + continue + } + + blockValType := blockVal.Type() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + + for _, v := range listVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + } + + default: + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + } + } + + return diags +} + +// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null +// values that are writeOnly to null. +func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { + if !val.IsKnown() || val.IsNull() { + return val + } + + valMap := val.AsValueMap() + newVals := make(map[string]cty.Value) + + for name, attr := range schema.Attributes { + v := valMap[name] + + if attr.WriteOnly && !v.IsNull() { + newVals[name] = cty.NullVal(attr.Type) + continue + } + + newVals[name] = v + } + + for name, blockS := range schema.BlockTypes { + blockVal := valMap[name] + if blockVal.IsNull() || !blockVal.IsKnown() { + newVals[name] = blockVal + continue + } + + blockValType := blockVal.Type() + blockElementType := blockS.Block.ImpliedType() + + // This switches on the value type here, so we can correctly switch + // between Tuples/Lists and Maps/Objects. + switch { + case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: + // NestingSingle is the only exception here, where we treat the + // block directly as an object + newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) + + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + listVals := blockVal.AsValueSlice() + newListVals := make([]cty.Value, 0, len(listVals)) + + for _, v := range listVals { + newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) + } + + switch { + case blockValType.IsSetType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.SetValEmpty(blockElementType) + default: + newVals[name] = cty.SetVal(newListVals) + } + case blockValType.IsListType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.ListValEmpty(blockElementType) + default: + newVals[name] = cty.ListVal(newListVals) + } + case blockValType.IsTupleType(): + newVals[name] = cty.TupleVal(newListVals) + } + + case blockValType.IsMapType(), blockValType.IsObjectType(): + mapVals := blockVal.AsValueMap() + newMapVals := make(map[string]cty.Value) + + for k, v := range mapVals { + newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) + } + + switch { + case blockValType.IsMapType(): + switch len(newMapVals) { + case 0: + newVals[name] = cty.MapValEmpty(blockElementType) + default: + newVals[name] = cty.MapVal(newMapVals) + } + case blockValType.IsObjectType(): + if len(newMapVals) == 0 { + // We need to populate empty values to make a valid object. + for attr, ty := range blockElementType.AttributeTypes() { + newMapVals[attr] = cty.NullVal(ty) + } + } + newVals[name] = cty.ObjectVal(newMapVals) + } + + default: + panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) + } + } + + return cty.ObjectVal(newVals) +} diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go new file mode 100644 index 00000000000..8620edc5415 --- /dev/null +++ b/helper/schema/write_only_test.go @@ -0,0 +1,1133 @@ +package schema + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/go-cty/cty" + + "github.com/hashicorp/terraform-plugin-sdk/v2/diag" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" +) + +func Test_validateWriteOnlyNullValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected diag.Diagnostics + }{ + "Empty returns no diags": { + &configschema.Block{}, + cty.EmptyObjectVal, + diag.Diagnostics{}, + }, + "All null values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + diag.Diagnostics{}, + }, + "Set nested block WriteOnly attribute with value returns diag": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("foo_val"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", + }, + }, + }, + "Nested single block, WriteOnly attribute with value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("beep"), + "baz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.StringVal("boop2"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with dynamic value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NumberIntVal(8), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", + }, + }, + }, + } { + t.Run(n, func(t *testing.T) { + got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) + + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func Test_validateWriteOnlyRequiredValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected diag.Diagnostics + }{ + "Empty returns no diags": { + &configschema.Block{}, + cty.EmptyObjectVal, + diag.Diagnostics{}, + }, + "All Required + WriteOnly with values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.StringVal("blep"), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + "biz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{}, + }, + "All Optional + WriteOnly with null values return no diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + diag.Diagnostics{}, + }, + "Set nested block Required + WriteOnly attribute with null return diags": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.NullVal(cty.String), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"foo\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"boz\"", + }, + }, + }, + "Nested single block, Required + WriteOnly attribute with null returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + }, + }, + "Map nested block, Required + WriteOnly attribute with null value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "baz": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + }, + }, + }, + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + }, + }, + }, + } { + t.Run(n, func(t *testing.T) { + got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) + + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) + } + }) + } +} + +func Test_setWriteOnlyNullValues(t *testing.T) { + for n, tc := range map[string]struct { + Schema *configschema.Block + Val cty.Value + Expected cty.Value + }{ + "Empty returns no empty object": { + &configschema.Block{}, + cty.EmptyObjectVal, + cty.EmptyObjectVal, + }, + "Top level attributes and block: write only attributes with values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.StringVal("blep"), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("blep"), + "biz": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "bar": cty.NullVal(cty.String), + "baz": cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + "biz": cty.StringVal("boop"), + }), + }), + }, + "Top level attributes and block: all null values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "biz": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + cty.NullVal(cty.Object(map[string]cty.Type{ + "foo": cty.String, + "bar": cty.String, + "baz": cty.Object(map[string]cty.Type{ + "boz": cty.String, + "biz": cty.String, + }), + })), + }, + "Set nested block: write only Nested Attribute": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "foo": { + Type: cty.String, + Required: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "baz": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "boz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.StringVal("beep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.StringVal("boop"), + "baz": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "boz": cty.NullVal(cty.String), + }), + }), + }), + }, + "Nested single block: write only nested attribute": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + }), + }, + "Map nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.StringVal("boop2"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "List nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("beep"), + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.StringVal("blep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.StringVal("blep"), + }), + }), + }), + }, + "Set nested block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingSet, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("boop"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + "Set nested Map block: multiple write only nested attributes": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "foo": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "bar": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "baz": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "foo": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.NullVal(cty.String), + "baz": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "bar": cty.StringVal("blep"), + "baz": cty.NullVal(cty.String), + }), + }), + }), + }, + } { + t.Run(n, func(t *testing.T) { + got := setWriteOnlyNullValues(tc.Val, tc.Schema) + + if !got.RawEquals(tc.Expected) { + t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) + } + }) + } +} From 21cebbe45782119a106ed71fd89be28e6a590490 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 17 Sep 2024 16:25:59 -0400 Subject: [PATCH 16/58] Refactor test attribute names for clarity --- helper/schema/write_only.go | 166 +++--- helper/schema/write_only_test.go | 925 +++++++++++++++---------------- 2 files changed, 522 insertions(+), 569 deletions(-) diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index db62f33f4d1..cabf94b6d89 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -9,35 +9,36 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" ) -// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an -// error diagnostic for each non-null writeOnly attribute value. -func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { +// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null +// values that are writeOnly to null. +func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { if !val.IsKnown() || val.IsNull() { - return diag.Diagnostics{} + return val } valMap := val.AsValueMap() - diags := make([]diag.Diagnostic, 0) + newVals := make(map[string]cty.Value) for name, attr := range schema.Attributes { v := valMap[name] if attr.WriteOnly && !v.IsNull() { - diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), - }) + newVals[name] = cty.NullVal(attr.Type) + continue } + + newVals[name] = v } for name, blockS := range schema.BlockTypes { blockVal := valMap[name] if blockVal.IsNull() || !blockVal.IsKnown() { + newVals[name] = blockVal continue } blockValType := blockVal.Type() + blockElementType := blockS.Block.ImpliedType() // This switches on the value type here, so we can correctly switch // between Tuples/Lists and Maps/Objects. @@ -45,32 +46,72 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) + newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) + case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() + newListVals := make([]cty.Value, 0, len(listVals)) for _, v := range listVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) + } + + switch { + case blockValType.IsSetType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.SetValEmpty(blockElementType) + default: + newVals[name] = cty.SetVal(newListVals) + } + case blockValType.IsListType(): + switch len(newListVals) { + case 0: + newVals[name] = cty.ListValEmpty(blockElementType) + default: + newVals[name] = cty.ListVal(newListVals) + } + case blockValType.IsTupleType(): + newVals[name] = cty.TupleVal(newListVals) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() + newMapVals := make(map[string]cty.Value) - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + for k, v := range mapVals { + newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) + } + + switch { + case blockValType.IsMapType(): + switch len(newMapVals) { + case 0: + newVals[name] = cty.MapValEmpty(blockElementType) + default: + newVals[name] = cty.MapVal(newMapVals) + } + case blockValType.IsObjectType(): + if len(newMapVals) == 0 { + // We need to populate empty values to make a valid object. + for attr, ty := range blockElementType.AttributeTypes() { + newMapVals[attr] = cty.NullVal(ty) + } + } + newVals[name] = cty.ObjectVal(newMapVals) } default: - panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) + panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) } } - return diags + return cty.ObjectVal(newVals) } -// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an -// error diagnostic for every WriteOnly + Required attribute null value. -func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { +// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for each non-null writeOnly attribute value. +func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { return diag.Diagnostics{} } @@ -81,11 +122,11 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con for name, attr := range schema.Attributes { v := valMap[name] - if attr.WriteOnly && attr.Required && v.IsNull() { + if attr.WriteOnly && !v.IsNull() { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + Summary: "WriteOnly Attribute Not Allowed", + Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), }) } } @@ -104,19 +145,19 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) + diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() for _, v := range listVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() for _, v := range mapVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) } default: @@ -127,36 +168,35 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con return diags } -// setWriteOnlyNullValues takes a cty.Value, and compares it to the schema setting any non-null -// values that are writeOnly to null. -func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value { +// validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an +// error diagnostic for every WriteOnly + Required attribute null value. +func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { - return val + return diag.Diagnostics{} } valMap := val.AsValueMap() - newVals := make(map[string]cty.Value) + diags := make([]diag.Diagnostic, 0) for name, attr := range schema.Attributes { v := valMap[name] - if attr.WriteOnly && !v.IsNull() { - newVals[name] = cty.NullVal(attr.Type) - continue + if attr.WriteOnly && attr.Required && v.IsNull() { + diags = append(diags, diag.Diagnostic{ + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + }) } - - newVals[name] = v } for name, blockS := range schema.BlockTypes { blockVal := valMap[name] if blockVal.IsNull() || !blockVal.IsKnown() { - newVals[name] = blockVal continue } blockValType := blockVal.Type() - blockElementType := blockS.Block.ImpliedType() // This switches on the value type here, so we can correctly switch // between Tuples/Lists and Maps/Objects. @@ -164,65 +204,25 @@ func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - newVals[name] = setWriteOnlyNullValues(blockVal, &blockS.Block) - + diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() - newListVals := make([]cty.Value, 0, len(listVals)) for _, v := range listVals { - newListVals = append(newListVals, setWriteOnlyNullValues(v, &blockS.Block)) - } - - switch { - case blockValType.IsSetType(): - switch len(newListVals) { - case 0: - newVals[name] = cty.SetValEmpty(blockElementType) - default: - newVals[name] = cty.SetVal(newListVals) - } - case blockValType.IsListType(): - switch len(newListVals) { - case 0: - newVals[name] = cty.ListValEmpty(blockElementType) - default: - newVals[name] = cty.ListVal(newListVals) - } - case blockValType.IsTupleType(): - newVals[name] = cty.TupleVal(newListVals) + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() - newMapVals := make(map[string]cty.Value) - - for k, v := range mapVals { - newMapVals[k] = setWriteOnlyNullValues(v, &blockS.Block) - } - switch { - case blockValType.IsMapType(): - switch len(newMapVals) { - case 0: - newVals[name] = cty.MapValEmpty(blockElementType) - default: - newVals[name] = cty.MapVal(newMapVals) - } - case blockValType.IsObjectType(): - if len(newMapVals) == 0 { - // We need to populate empty values to make a valid object. - for attr, ty := range blockElementType.AttributeTypes() { - newMapVals[attr] = cty.NullVal(ty) - } - } - newVals[name] = cty.ObjectVal(newMapVals) + for _, v := range mapVals { + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) } default: - panic(fmt.Sprintf("failed to set null values for nested block %q:%#v", name, blockValType)) + panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) } } - return cty.ObjectVal(newVals) + return diags } diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 8620edc5415..7b8e9dbb417 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -10,42 +10,91 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" ) -func Test_validateWriteOnlyNullValues(t *testing.T) { +func Test_setWriteOnlyNullValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block Val cty.Value - Expected diag.Diagnostics + Expected cty.Value }{ - "Empty returns no diags": { + "Empty returns no empty object": { &configschema.Block{}, cty.EmptyObjectVal, - diag.Diagnostics{}, + cty.EmptyObjectVal, }, - "All null values return no diags": { + "Top level attributes and block: write only attributes with values": { + &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_attribute": { + Type: cty.String, + Required: true, + }, + "write_only_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_block": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_block_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "required_block_attribute": { + Type: cty.String, + Required: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "required_attribute": cty.StringVal("boop"), + "write_only_attribute": cty.StringVal("blep"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("blep"), + "required_block_attribute": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "required_attribute": cty.StringVal("boop"), + "write_only_attribute": cty.NullVal(cty.String), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + "required_block_attribute": cty.StringVal("boop"), + }), + }), + }, + "Top level attributes and block: all null values": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { + "write_only_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "bar": { + "write_only_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "write_only_block_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "biz": { + "write_only_block_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, @@ -56,32 +105,38 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, + "write_only_attribute1": cty.String, + "write_only_attribute2": cty.String, + "nested_block": cty.Object(map[string]cty.Type{ + "write_only_block_attribute1": cty.String, + "write_only_block_attribute2": cty.String, + }), + })), + cty.NullVal(cty.Object(map[string]cty.Type{ + "write_only_attribute1": cty.String, + "write_only_attribute2": cty.String, + "nested_block": cty.Object(map[string]cty.Type{ + "write_only_block_attribute1": cty.String, + "write_only_block_attribute2": cty.String, }), })), - diag.Diagnostics{}, }, - "Set nested block WriteOnly attribute with value returns diag": { + "Set nested block: write only Nested Attribute": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Optional: true, - WriteOnly: true, + "required_attribute": { + Type: cty.String, + Required: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "write_only_block_attribute": { Type: cty.String, - Optional: true, + Required: true, WriteOnly: true, }, }, @@ -90,39 +145,35 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("foo_val"), - "baz": cty.SetVal([]cty.Value{ + "required_attribute": cty.StringVal("boop"), + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("beep"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "required_attribute": cty.StringVal("boop"), + "set_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"boz\"", - }, - }, }, - "Nested single block, WriteOnly attribute with value returns diag": { + "Nested single block: write only nested attribute": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, - Optional: true, + Required: true, WriteOnly: true, }, - "baz": { + "optional_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -133,34 +184,33 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("beep"), - "baz": cty.StringVal("boop"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("boop"), + "optional_attribute": cty.StringVal("boop"), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_attribute": cty.StringVal("boop"), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, }, - "Map nested block, WriteOnly attribute with value returns diag": { + "Map nested block: multiple write only nested attributes": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "write_only_block_attribute": { Type: cty.String, - Optional: true, + Required: true, WriteOnly: true, }, }, @@ -169,38 +219,43 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.StringVal("boop"), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("boop2"), + }), + }), + }), + cty.ObjectVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NullVal(cty.String), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.NullVal(cty.String), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + "List nested block: multiple write only nested attributes": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "list_block": { Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, - Optional: true, + Required: true, WriteOnly: true, }, - "baz": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -211,172 +266,89 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("bap"), + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute": cty.StringVal("bap"), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("boop"), + "optional_block_attribute": cty.StringVal("blep"), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, - }, - "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "baz": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("bap"), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", - }, - }, }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + "Set nested block: multiple write only nested attributes": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingMap, + "set_block": { + Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { + "write_only_block_attribute": { Type: cty.String, Optional: true, WriteOnly: true, }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, }, }, }, }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), + "set_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("blep"), + "optional_block_attribute": cty.NullVal(cty.String), }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.StringVal("boop2"), + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("boop"), + "optional_block_attribute": cty.StringVal("boop"), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, - }, - "List nested block, WriteOnly attribute with dynamic value returns diag": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { - Nesting: configschema.NestingList, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "bar": { - Type: cty.String, - Optional: true, - Computed: true, - }, - "baz": { - Type: cty.DynamicPseudoType, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.TupleVal([]cty.Value{ + "set_block": cty.SetVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.NullVal(cty.String), + }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NumberIntVal(8), + "write_only_block_attribute": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("boop"), }), }), }), - diag.Diagnostics{ - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"baz\"", - }, - }, }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) + got := setWriteOnlyNullValues(tc.Val, tc.Schema) - if diff := cmp.Diff(got, tc.Expected); diff != "" { - t.Errorf("unexpected difference: %s", diff) + if !got.RawEquals(tc.Expected) { + t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) } }) } } -func Test_validateWriteOnlyRequiredValues(t *testing.T) { +func Test_validateWriteOnlyNullValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block Val cty.Value @@ -387,75 +359,31 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { cty.EmptyObjectVal, diag.Diagnostics{}, }, - "All Required + WriteOnly with values return no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "bar": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "boz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "biz": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.StringVal("blep"), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - "biz": cty.StringVal("boop"), - }), - }), - diag.Diagnostics{}, - }, - "All Optional + WriteOnly with null values return no diags": { + "All null values return no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { + "write_only_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "bar": { + "write_only_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "single_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "write_only_block_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "biz": { + "write_only_block_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, @@ -466,32 +394,32 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, + "write_only_attribute1": cty.String, + "write_only_attribute2": cty.String, + "single_block": cty.Object(map[string]cty.Type{ + "write_only_block_attribute1": cty.String, + "write_only_block_attribute2": cty.String, }), })), diag.Diagnostics{}, }, - "Set nested block Required + WriteOnly attribute with null return diags": { + "Set nested block WriteOnly attribute with value returns diag": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { + "write_only_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, @@ -500,39 +428,39 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.NullVal(cty.String), - "baz": cty.SetVal([]cty.Value{ + "write_only_attribute": cty.StringVal("val"), + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), + "write_only_block_attribute": cty.StringVal("block_val"), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"foo\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_attribute\"", }, { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"boz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, - "Nested single block, Required + WriteOnly attribute with null returns diag": { + "Nested single block, WriteOnly attribute with value returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, - "baz": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -543,33 +471,34 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("boop"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute1": cty.StringVal("boop"), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, - "Map nested block, Required + WriteOnly attribute with null value returns diag": { + "Map nested block, WriteOnly attribute with value returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, @@ -578,38 +507,38 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), + "optional_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.StringVal("boop"), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.NullVal(cty.String), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, "List nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "list_block": { Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, - "baz": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -620,43 +549,43 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("bap"), + "write_only_block_attribute": cty.StringVal("bap"), }), cty.ObjectVal(map[string]cty.Value{ - "baz": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("blep"), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"bar\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "baz": { + "write_only_block_attribute2": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, @@ -665,45 +594,45 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "write_only_block_attribute1": cty.StringVal("blep"), + "write_only_block_attribute2": cty.NullVal(cty.String), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), + "write_only_block_attribute1": cty.StringVal("boop"), + "write_only_block_attribute2": cty.NullVal(cty.String), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\"", }, { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\"", }, }, }, "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "write_only_block_attribute": { Type: cty.String, - Required: true, + Optional: true, WriteOnly: true, }, }, @@ -712,33 +641,71 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.StringVal("boop"), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("boop2"), }), }), }), diag.Diagnostics{ { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, { Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"baz\"", + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + }, + }, + }, + "List nested block, WriteOnly attribute with dynamic value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "list_block": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "write_only_block_attribute": { + Type: cty.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "list_block": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NumberIntVal(8), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", }, }, }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) + got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) if diff := cmp.Diff(got, tc.Expected); diff != "" { t.Errorf("unexpected difference: %s", diff) @@ -747,43 +714,45 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { } } -func Test_setWriteOnlyNullValues(t *testing.T) { +func Test_validateWriteOnlyRequiredValues(t *testing.T) { for n, tc := range map[string]struct { Schema *configschema.Block Val cty.Value - Expected cty.Value + Expected diag.Diagnostics }{ - "Empty returns no empty object": { + "Empty returns no diags": { &configschema.Block{}, cty.EmptyObjectVal, - cty.EmptyObjectVal, + diag.Diagnostics{}, }, - "Top level attributes and block: write only attributes with values": { + "All Required + WriteOnly with values return no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, + "required_write_only_attribute1": { + Type: cty.String, + Required: true, + WriteOnly: true, }, - "bar": { + "required_write_only_attribute2": { Type: cty.String, Required: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "required_write_only_block_attribute1": { Type: cty.String, Required: true, WriteOnly: true, }, - "biz": { - Type: cty.String, - Required: true, + "required_write_only_block_attribute2": { + Type: cty.String, + Required: true, + WriteOnly: true, }, }, }, @@ -791,47 +760,40 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.StringVal("blep"), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("blep"), - "biz": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "bar": cty.NullVal(cty.String), - "baz": cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), - "biz": cty.StringVal("boop"), + "required_write_only_attribute1": cty.StringVal("boop"), + "required_write_only_attribute2": cty.StringVal("blep"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute1": cty.StringVal("blep"), + "required_write_only_block_attribute2": cty.StringVal("boop"), }), }), + diag.Diagnostics{}, }, - "Top level attributes and block: all null values": { + "All Optional + WriteOnly with null values return no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { + "optional_write_only_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "bar": { + "optional_write_only_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "optional_write_only_block_attribute1": { Type: cty.String, Optional: true, WriteOnly: true, }, - "biz": { + "optional_write_only_block_attribute2": { Type: cty.String, Optional: true, WriteOnly: true, @@ -842,36 +804,30 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, - }), - })), - cty.NullVal(cty.Object(map[string]cty.Type{ - "foo": cty.String, - "bar": cty.String, - "baz": cty.Object(map[string]cty.Type{ - "boz": cty.String, - "biz": cty.String, + "optional_write_only_attribute1": cty.String, + "optional_write_only_attribute2": cty.String, + "nested_block": cty.Object(map[string]cty.Type{ + "optional_write_only_block_attribute1": cty.String, + "optional_write_only_block_attribute2": cty.String, }), })), + diag.Diagnostics{}, }, - "Set nested block: write only Nested Attribute": { + "Set nested block Required + WriteOnly attribute with null return diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "foo": { - Type: cty.String, - Required: true, + "required_write_only_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, }, }, BlockTypes: map[string]*configschema.NestedBlock{ - "baz": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "boz": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, @@ -882,35 +838,39 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "baz": cty.SetVal([]cty.Value{ + "required_write_only_attribute": cty.NullVal(cty.String), + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "boz": cty.StringVal("beep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.StringVal("boop"), - "baz": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "boz": cty.NullVal(cty.String), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, - "Nested single block: write only nested attribute": { + "Nested single block, Required + WriteOnly attribute with null returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "nested_block": { Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, }, - "baz": { + "optional_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -921,31 +881,31 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.StringVal("boop"), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "optional_attribute": cty.StringVal("boop"), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", + }, + }, }, - "Map nested block: multiple write only nested attributes": { + "Map nested block, Required + WriteOnly attribute with null value returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, @@ -956,43 +916,38 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.StringVal("boop2"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.NullVal(cty.String), + "required_write_only_block_attribute": cty.StringVal("boop"), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, - "List nested block: multiple write only nested attributes": { + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "list_block": { Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, }, - "baz": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -1003,43 +958,41 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("beep"), - "baz": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.StringVal("blep"), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.ListVal([]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("bap"), + "optional_block_attribute": cty.StringVal("bap"), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.StringVal("blep"), + "optional_block_attribute": cty.StringVal("blep"), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, - "Set nested block: multiple write only nested attributes": { + "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "set_block": { Nesting: configschema.NestingSet, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_write_only_block_attribute": { Type: cty.String, Optional: true, WriteOnly: true, }, - "baz": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, @@ -1050,43 +1003,43 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("boop"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.SetVal([]cty.Value{ + "set_block": cty.SetVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_write_only_block_attribute": cty.StringVal("blep"), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_write_only_block_attribute": cty.StringVal("boop"), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, - "Set nested Map block: multiple write only nested attributes": { + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "foo": { + "map_block": { Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "bar": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, - "baz": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, @@ -1097,36 +1050,36 @@ func Test_setWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), - }), - }), - }), - cty.ObjectVal(map[string]cty.Value{ - "foo": cty.MapVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.NullVal(cty.String), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.NullVal(cty.String), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), "b": cty.ObjectVal(map[string]cty.Value{ - "bar": cty.StringVal("blep"), - "baz": cty.NullVal(cty.String), + "optional_block_attribute": cty.StringVal("blep"), + "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + }, + }, }, } { t.Run(n, func(t *testing.T) { - got := setWriteOnlyNullValues(tc.Val, tc.Schema) + got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) - if !got.RawEquals(tc.Expected) { - t.Errorf("\nexpected: %#v\ngot: %#v\n", tc.Expected, got) + if diff := cmp.Diff(got, tc.Expected); diff != "" { + t.Errorf("unexpected difference: %s", diff) } }) } From a276ff4f7b279d3c32be636df7262a86218a9fdf Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 14:28:18 -0400 Subject: [PATCH 17/58] Refactor `validateWriteOnlyNullValues()` to build an attribute path.` --- helper/schema/grpc_provider.go | 2 +- helper/schema/write_only.go | 60 ++++++++-- helper/schema/write_only_test.go | 196 +++++++++++++++++++++++++++---- 3 files changed, 223 insertions(+), 35 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 13fd33b9d34..51b83732d30 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -282,7 +282,7 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req return resp, nil } if req.ClientCapabilities == nil || !req.ClientCapabilities.WriteOnlyAttributesAllowed { - resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock)) + resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateWriteOnlyNullValues(req.TypeName, configVal, schemaBlock, cty.Path{})) } r := s.provider.ResourcesMap[req.TypeName] diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index cabf94b6d89..7a375e0b64f 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -2,6 +2,7 @@ package schema import ( "fmt" + "sort" "github.com/hashicorp/go-cty/cty" @@ -109,9 +110,13 @@ func setWriteOnlyNullValues(val cty.Value, schema *configschema.Block) cty.Value return cty.ObjectVal(newVals) } -// validateWriteOnlyNullValues takes a cty.Value, and compares it to the schema and throws an +// validateWriteOnlyNullValues validates that write-only attribute values +// are null to ensure that write-only values are not sent to unsupported +// Terraform client versions. +// +// it takes a cty.Value, and compares it to the schema and throws an // error diagnostic for each non-null writeOnly attribute value. -func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { +func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configschema.Block, path cty.Path) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { return diag.Diagnostics{} } @@ -119,25 +124,42 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs valMap := val.AsValueMap() diags := make([]diag.Diagnostic, 0) - for name, attr := range schema.Attributes { + var attrNames []string + for k := range schema.Attributes { + attrNames = append(attrNames, k) + } + sort.Strings(attrNames) + + for _, name := range attrNames { + attr := schema.Attributes[name] v := valMap[name] if attr.WriteOnly && !v.IsNull() { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: fmt.Sprintf("The %q resource contains a non-null value for WriteOnly attribute %q", typeName, name), + Detail: fmt.Sprintf("The resource contains a non-null value for WriteOnly attribute %q ", name) + + fmt.Sprintf("Write-only attributes are only supported in Terraform 1.11 and later."), + AttributePath: append(path, cty.GetAttrStep{Name: name}), }) } } - for name, blockS := range schema.BlockTypes { + var blockNames []string + for k := range schema.BlockTypes { + blockNames = append(blockNames, k) + } + sort.Strings(blockNames) + + for _, name := range blockNames { + blockS := schema.BlockTypes[name] blockVal := valMap[name] if blockVal.IsNull() || !blockVal.IsKnown() { continue } blockValType := blockVal.Type() + blockPath := append(path, cty.GetAttrStep{Name: name}) // This switches on the value type here, so we can correctly switch // between Tuples/Lists and Maps/Objects. @@ -145,19 +167,35 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block)...) - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + diags = append(diags, validateWriteOnlyNullValues(typeName, blockVal, &blockS.Block, blockPath)...) + case blockValType.IsSetType(): + setVals := blockVal.AsValueSlice() + + for _, v := range setVals { + setBlockPath := append(blockPath, cty.IndexStep{ + Key: v, + }) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, setBlockPath)...) + } + + case blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() - for _, v := range listVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + for i, v := range listVals { + listBlockPath := append(blockPath, cty.IndexStep{ + Key: cty.NumberIntVal(int64(i)), + }) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, listBlockPath)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block)...) + for k, v := range mapVals { + mapBlockPath := append(blockPath, cty.IndexStep{ + Key: cty.StringVal(k), + }) + diags = append(diags, validateWriteOnlyNullValues(typeName, v, &blockS.Block, mapBlockPath)...) } default: diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 7b8e9dbb417..e0893805aed 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -439,12 +439,24 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "write_only_attribute"}, + }, }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("block_val"), + })}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -480,7 +492,12 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block"}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -522,7 +539,13 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -562,12 +585,24 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(1)}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -609,12 +644,30 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute1": cty.StringVal("blep"), + "write_only_block_attribute2": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "write_only_block_attribute1"}, + }, }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute1": cty.StringVal("boop"), + "write_only_block_attribute2": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "write_only_block_attribute1"}, + }, }, }, }, @@ -656,12 +709,24 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("b")}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -699,15 +764,96 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, + }, + }, + }, + "multiple nested blocks, multiple WriteOnly attributes with value returns diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_block1": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_block_attribute": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + "nested_block2": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_block_attribute": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "nested_block1": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute1": cty.StringVal("boop"), + }), + "nested_block2": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute1": cty.StringVal("boop"), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block1"}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block2"}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema) + got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema, cty.Path{}) - if diff := cmp.Diff(got, tc.Expected); diff != "" { + if diff := cmp.Diff(got, tc.Expected, + cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), + cmp.Comparer(indexStepComparer)); diff != "" { t.Errorf("unexpected difference: %s", diff) } }) @@ -849,12 +995,12 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -889,7 +1035,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", }, }, }, @@ -931,7 +1077,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -971,12 +1117,12 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -1018,12 +1164,12 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -1065,12 +1211,12 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The \"test_resource\" resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", }, }, }, @@ -1084,3 +1230,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }) } } + +func indexStepComparer(step cty.IndexStep, other cty.IndexStep) bool { + return true +} From fac41b53a8ae4c8196d03a7e73ff3bece70a9e76 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 15:12:11 -0400 Subject: [PATCH 18/58] Refactor `validateWriteOnlyRequiredValues()` to build an attribute path.` --- helper/schema/grpc_provider.go | 2 +- helper/schema/grpc_provider_test.go | 30 +- helper/schema/write_only.go | 61 +++- helper/schema/write_only_test.go | 437 ++++++++++++++++++++++------ 4 files changed, 414 insertions(+), 116 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 51b83732d30..5abb03d4359 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -847,7 +847,7 @@ func (s *GRPCProviderServer) PlanResourceChange(ctx context.Context, req *tfprot // If the resource is being created, validate that all required write-only // attributes in the config have non-nil values. if create { - diags := validateWriteOnlyRequiredValues(req.TypeName, configVal, schemaBlock) + diags := validateWriteOnlyRequiredValues(req.TypeName, configVal, schemaBlock, cty.Path{}) if diags.HasError() { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, diags) return resp, nil diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index f7b04a42225..e620c970737 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3579,7 +3579,9 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"foo\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, }, }, @@ -3625,12 +3627,16 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"bar\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath().WithAttributeName("bar"), }, { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"bar\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"foo\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, }, }, @@ -3703,12 +3709,19 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"foo\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"foo\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, { Severity: tfprotov5.DiagnosticSeverityError, Summary: "WriteOnly Attribute Not Allowed", - Detail: "The \"test_resource\" resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\"", + Detail: "The resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + Attribute: tftypes.NewAttributePath(). + WithAttributeName("config_block_attr"). + WithElementKeyInt(0). + WithAttributeName("writeonly_nested_attr"), }, }, }, @@ -5069,9 +5082,10 @@ func TestPlanResourceChange(t *testing.T) { expected: &tfprotov5.PlanResourceChangeResponse{ Diagnostics: []*tfprotov5.Diagnostic{ { - Severity: tfprotov5.DiagnosticSeverityError, - Summary: "Required WriteOnly Attribute", - Detail: "The \"test\" resource contains a null value for Required WriteOnly attribute \"foo\"", + Severity: tfprotov5.DiagnosticSeverityError, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"foo\"", + Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, }, UnsafeToUseLegacyTypeSystem: true, diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index 7a375e0b64f..a87db12cd89 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -128,6 +128,8 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs for k := range schema.Attributes { attrNames = append(attrNames, k) } + + // Sort the attribute names to produce diags in a consistent order. sort.Strings(attrNames) for _, name := range attrNames { @@ -149,6 +151,8 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs for k := range schema.BlockTypes { blockNames = append(blockNames, k) } + + // Sort the block names to produce diags in a consistent order. sort.Strings(blockNames) for _, name := range blockNames { @@ -208,7 +212,7 @@ func validateWriteOnlyNullValues(typeName string, val cty.Value, schema *configs // validateWriteOnlyRequiredValues takes a cty.Value, and compares it to the schema and throws an // error diagnostic for every WriteOnly + Required attribute null value. -func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block) diag.Diagnostics { +func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *configschema.Block, path cty.Path) diag.Diagnostics { if !val.IsKnown() || val.IsNull() { return diag.Diagnostics{} } @@ -216,25 +220,44 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con valMap := val.AsValueMap() diags := make([]diag.Diagnostic, 0) + var attrNames []string + for k := range schema.Attributes { + attrNames = append(attrNames, k) + } + + // Sort the attribute names to produce diags in a consistent order. + sort.Strings(attrNames) + for name, attr := range schema.Attributes { v := valMap[name] if attr.WriteOnly && attr.Required && v.IsNull() { diags = append(diags, diag.Diagnostic{ - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: fmt.Sprintf("The %q resource contains a null value for Required WriteOnly attribute %q", typeName, name), + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: fmt.Sprintf("The resource contains a null value for Required WriteOnly attribute %q", name), + AttributePath: append(path, cty.GetAttrStep{Name: name}), }) } } - for name, blockS := range schema.BlockTypes { + var blockNames []string + for k := range schema.BlockTypes { + blockNames = append(blockNames, k) + } + + // Sort the block names to produce diags in a consistent order. + sort.Strings(blockNames) + + for _, name := range blockNames { + blockS := schema.BlockTypes[name] blockVal := valMap[name] if blockVal.IsNull() || !blockVal.IsKnown() { continue } blockValType := blockVal.Type() + blockPath := append(path, cty.GetAttrStep{Name: name}) // This switches on the value type here, so we can correctly switch // between Tuples/Lists and Maps/Objects. @@ -242,19 +265,35 @@ func validateWriteOnlyRequiredValues(typeName string, val cty.Value, schema *con case blockS.Nesting == configschema.NestingSingle || blockS.Nesting == configschema.NestingGroup: // NestingSingle is the only exception here, where we treat the // block directly as an object - diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block)...) - case blockValType.IsSetType(), blockValType.IsListType(), blockValType.IsTupleType(): + diags = append(diags, validateWriteOnlyRequiredValues(typeName, blockVal, &blockS.Block, blockPath)...) + case blockValType.IsSetType(): + setVals := blockVal.AsValueSlice() + + for _, v := range setVals { + setBlockPath := append(blockPath, cty.IndexStep{ + Key: v, + }) + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, setBlockPath)...) + } + + case blockValType.IsListType(), blockValType.IsTupleType(): listVals := blockVal.AsValueSlice() - for _, v := range listVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + for i, v := range listVals { + listBlockPath := append(blockPath, cty.IndexStep{ + Key: cty.NumberIntVal(int64(i)), + }) + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, listBlockPath)...) } case blockValType.IsMapType(), blockValType.IsObjectType(): mapVals := blockVal.AsValueMap() - for _, v := range mapVals { - diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block)...) + for k, v := range mapVals { + mapBlockPath := append(blockPath, cty.IndexStep{ + Key: cty.StringVal(k), + }) + diags = append(diags, validateWriteOnlyRequiredValues(typeName, v, &blockS.Block, mapBlockPath)...) } default: diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index e0893805aed..03889af6158 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -359,7 +359,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { cty.EmptyObjectVal, diag.Diagnostics{}, }, - "All null values return no diags": { + "All null values returns no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "write_only_attribute1": { @@ -460,11 +460,18 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, }, - "Nested single block, WriteOnly attribute with value returns diag": { + "List nested block, WriteOnly attribute with value returns diag": { &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "write_only_attribute": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, BlockTypes: map[string]*configschema.NestedBlock{ - "nested_block": { - Nesting: configschema.NestingSingle, + "list_block": { + Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ "write_only_block_attribute": { @@ -483,19 +490,31 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "nested_block": cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("beep"), - "optional_block_attribute1": cty.StringVal("boop"), + "write_only_attribute": cty.StringVal("val"), + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("bap"), + }), }), }), diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "write_only_attribute"}, + }, + }, { Severity: diag.Error, Summary: "WriteOnly Attribute Not Allowed", Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "nested_block"}, + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, @@ -549,11 +568,11 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + "Nested single block, WriteOnly attribute with value returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "list_block": { - Nesting: configschema.NestingList, + "nested_block": { + Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ "write_only_block_attribute": { @@ -572,13 +591,9 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "list_block": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "write_only_block_attribute": cty.StringVal("blep"), - }), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("beep"), + "optional_block_attribute1": cty.StringVal("boop"), }), }), diag.Diagnostics{ @@ -588,19 +603,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "list_block"}, - cty.IndexStep{Key: cty.NumberIntVal(0)}, - cty.GetAttrStep{Name: "write_only_block_attribute"}, - }, - }, - { - Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + - "Write-only attributes are only supported in Terraform 1.11 and later.", - AttributePath: cty.Path{ - cty.GetAttrStep{Name: "list_block"}, - cty.IndexStep{Key: cty.NumberIntVal(1)}, + cty.GetAttrStep{Name: "nested_block"}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, @@ -671,37 +674,35 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "map_block": { - Nesting: configschema.NestingMap, + "list_block": { + Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "optional_block_attribute": { - Type: cty.String, - Optional: true, - Computed: true, - }, "write_only_block_attribute": { Type: cty.String, Optional: true, WriteOnly: true, }, + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, }, }, }, }, }, cty.ObjectVal(map[string]cty.Value{ - "map_block": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.NullVal(cty.String), - "write_only_block_attribute": cty.StringVal("boop"), + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("bap"), }), - "b": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("blep"), - "write_only_block_attribute": cty.StringVal("boop2"), + cty.ObjectVal(map[string]cty.Value{ + "write_only_block_attribute": cty.StringVal("blep"), }), }), }), @@ -712,8 +713,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "map_block"}, - cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, @@ -723,18 +724,18 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "map_block"}, - cty.IndexStep{Key: cty.StringVal("b")}, + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(1)}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, }, }, - "List nested block, WriteOnly attribute with dynamic value returns diag": { + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "list_block": { - Nesting: configschema.NestingList, + "map_block": { + Nesting: configschema.NestingMap, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ "optional_block_attribute": { @@ -743,7 +744,7 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Computed: true, }, "write_only_block_attribute": { - Type: cty.DynamicPseudoType, + Type: cty.String, Optional: true, WriteOnly: true, }, @@ -753,10 +754,14 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "list_block": cty.TupleVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ + "map_block": cty.MapVal(map[string]cty.Value{ + "a": cty.ObjectVal(map[string]cty.Value{ "optional_block_attribute": cty.NullVal(cty.String), - "write_only_block_attribute": cty.NumberIntVal(8), + "write_only_block_attribute": cty.StringVal("boop"), + }), + "b": cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("blep"), + "write_only_block_attribute": cty.StringVal("boop2"), }), }), }), @@ -767,14 +772,25 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ - cty.GetAttrStep{Name: "list_block"}, - cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, + }, + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("b")}, cty.GetAttrStep{Name: "write_only_block_attribute"}, }, }, }, }, - "multiple nested blocks, multiple WriteOnly attributes with value returns diags": { + "Nested single block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ "nested_block1": { @@ -847,6 +863,50 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, }, }, + "List nested block, WriteOnly attribute with dynamic value returns diag": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "list_block": { + Nesting: configschema.NestingList, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "optional_block_attribute": { + Type: cty.String, + Optional: true, + Computed: true, + }, + "write_only_block_attribute": { + Type: cty.DynamicPseudoType, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "list_block": cty.TupleVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.NullVal(cty.String), + "write_only_block_attribute": cty.NumberIntVal(8), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "WriteOnly Attribute Not Allowed", + Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + "Write-only attributes are only supported in Terraform 1.11 and later.", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, + }, + }, + }, } { t.Run(n, func(t *testing.T) { got := validateWriteOnlyNullValues("test_resource", tc.Val, tc.Schema, cty.Path{}) @@ -871,7 +931,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { cty.EmptyObjectVal, diag.Diagnostics{}, }, - "All Required + WriteOnly with values return no diags": { + "All Required + WriteOnly with values returns no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "required_write_only_attribute1": { @@ -915,7 +975,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }), diag.Diagnostics{}, }, - "All Optional + WriteOnly with null values return no diags": { + "All Optional + WriteOnly with null values returns no diags": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "optional_write_only_attribute1": { @@ -959,7 +1019,7 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { })), diag.Diagnostics{}, }, - "Set nested block Required + WriteOnly attribute with null return diags": { + "Set nested block Required + WriteOnly attribute with null returns diag": { &configschema.Block{ Attributes: map[string]*configschema.Attribute{ "required_write_only_attribute": { @@ -996,27 +1056,44 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "required_write_only_attribute"}, + }, }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, }, }, - "Nested single block, Required + WriteOnly attribute with null returns diag": { + "List nested block Required + WriteOnly attribute with null returns diag": { &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_write_only_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + }, BlockTypes: map[string]*configschema.NestedBlock{ - "nested_block": { - Nesting: configschema.NestingSingle, + "list_block": { + Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "write_only_block_attribute": { + "required_write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, }, - "optional_attribute": { + "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -1027,15 +1104,31 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "nested_block": cty.ObjectVal(map[string]cty.Value{ - "optional_attribute": cty.StringVal("boop"), + "required_write_only_attribute": cty.NullVal(cty.String), + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute": cty.NullVal(cty.String), + }), }), }), diag.Diagnostics{ { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "required_write_only_attribute"}, + }, + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, }, }, @@ -1078,22 +1171,27 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("b")}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, }, }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { + "Nested single block, Required + WriteOnly attribute with null returns diag": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "list_block": { - Nesting: configschema.NestingList, + "nested_block": { + Nesting: configschema.NestingSingle, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ - "required_write_only_block_attribute": { + "write_only_block_attribute": { Type: cty.String, Required: true, WriteOnly: true, }, - "optional_block_attribute": { + "optional_attribute": { Type: cty.String, Optional: true, Computed: true, @@ -1104,25 +1202,19 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { }, }, cty.ObjectVal(map[string]cty.Value{ - "list_block": cty.ListVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("bap"), - }), - cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("blep"), - }), + "nested_block": cty.ObjectVal(map[string]cty.Value{ + "optional_attribute": cty.StringVal("boop"), }), }), diag.Diagnostics{ { Severity: diag.Error, Summary: "Required WriteOnly Attribute", - Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", - }, - { - Severity: diag.Error, - Summary: "Required WriteOnly Attribute", - Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + Detail: "The resource contains a null value for Required WriteOnly attribute \"write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block"}, + cty.GetAttrStep{Name: "write_only_block_attribute"}, + }, }, }, }, @@ -1165,26 +1257,97 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "optional_write_only_block_attribute": cty.StringVal("blep"), + "required_write_only_block_attribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "set_block"}, + cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ + "optional_write_only_block_attribute": cty.StringVal("boop"), + "required_write_only_block_attribute": cty.NullVal(cty.String), + })}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, }, }, - "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + "List nested block, WriteOnly attribute with multiple values returns multiple diags": { &configschema.Block{ BlockTypes: map[string]*configschema.NestedBlock{ - "map_block": { - Nesting: configschema.NestingMap, + "list_block": { + Nesting: configschema.NestingList, Block: configschema.Block{ Attributes: map[string]*configschema.Attribute{ + "required_write_only_block_attribute": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, "optional_block_attribute": { Type: cty.String, Optional: true, Computed: true, }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "list_block": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("bap"), + }), + cty.ObjectVal(map[string]cty.Value{ + "optional_block_attribute": cty.StringVal("blep"), + }), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(0)}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "list_block"}, + cty.IndexStep{Key: cty.NumberIntVal(1)}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, + }, + }, + }, + "Map nested block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "map_block": { + Nesting: configschema.NestingMap, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "optional_write_only_block_attribute": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, "required_write_only_block_attribute": { Type: cty.String, Required: true, @@ -1198,11 +1361,11 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { cty.ObjectVal(map[string]cty.Value{ "map_block": cty.MapVal(map[string]cty.Value{ "a": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.NullVal(cty.String), + "optional_write_only_block_attribute": cty.NullVal(cty.String), "required_write_only_block_attribute": cty.NullVal(cty.String), }), "b": cty.ObjectVal(map[string]cty.Value{ - "optional_block_attribute": cty.StringVal("blep"), + "optional_write_only_block_attribute": cty.StringVal("blep"), "required_write_only_block_attribute": cty.NullVal(cty.String), }), }), @@ -1212,19 +1375,101 @@ func Test_validateWriteOnlyRequiredValues(t *testing.T) { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("a")}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, }, { Severity: diag.Error, Summary: "Required WriteOnly Attribute", Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "map_block"}, + cty.IndexStep{Key: cty.StringVal("b")}, + cty.GetAttrStep{Name: "required_write_only_block_attribute"}, + }, + }, + }, + }, + "Nested single block, WriteOnly attribute with multiple values returns multiple diags": { + &configschema.Block{ + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_block1": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_write_only_block_attribute1": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "optional_write_only_block_attribute1": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + "nested_block2": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "required_write_only_block_attribute2": { + Type: cty.String, + Required: true, + WriteOnly: true, + }, + "optional_write_only_block_attribute2": { + Type: cty.String, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + }, + cty.ObjectVal(map[string]cty.Value{ + "nested_block1": cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute1": cty.NullVal(cty.String), + "optional_write_only_block_attribute1": cty.StringVal("boop"), + }), + "nested_block2": cty.ObjectVal(map[string]cty.Value{ + "required_write_only_block_attribute2": cty.NullVal(cty.String), + "optional_write_only_block_attribute2": cty.NullVal(cty.String), + }), + }), + diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute1\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block1"}, + cty.GetAttrStep{Name: "required_write_only_block_attribute1"}, + }, + }, + { + Severity: diag.Error, + Summary: "Required WriteOnly Attribute", + Detail: "The resource contains a null value for Required WriteOnly attribute \"required_write_only_block_attribute2\"", + AttributePath: cty.Path{ + cty.GetAttrStep{Name: "nested_block2"}, + cty.GetAttrStep{Name: "required_write_only_block_attribute2"}, + }, }, }, }, } { t.Run(n, func(t *testing.T) { - got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema) + got := validateWriteOnlyRequiredValues("test_resource", tc.Val, tc.Schema, cty.Path{}) - if diff := cmp.Diff(got, tc.Expected); diff != "" { + if diff := cmp.Diff(got, tc.Expected, + cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), + cmp.Comparer(indexStepComparer)); diff != "" { t.Errorf("unexpected difference: %s", diff) } }) From 7cf90244351c2df02f7e51add906b669fcd58156 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 15:22:52 -0400 Subject: [PATCH 19/58] Refactor field and function names based on PR feedback. --- helper/schema/grpc_provider.go | 6 ++--- helper/schema/grpc_provider_test.go | 38 ++++++++++++++--------------- helper/schema/resource.go | 15 +++++++----- helper/validation/path.go | 4 +-- helper/validation/path_test.go | 6 ++--- helper/validation/write_only.go | 6 ++--- 6 files changed, 39 insertions(+), 36 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 5abb03d4359..a49e5a41653 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -287,9 +287,9 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req r := s.provider.ResourcesMap[req.TypeName] - // Calling all ValidateResourceConfigFunc here since they validate on the raw go-cty config value + // Calling all ValidateRawResourceConfigFunc here since they validate on the raw go-cty config value // and were introduced after the public provider.ValidateResource method. - if r.ValidateResourceConfigFuncs != nil { + if r.ValidateRawResourceConfigFuncs != nil { writeOnlyAllowed := false if req.ClientCapabilities != nil { @@ -301,7 +301,7 @@ func (s *GRPCProviderServer) ValidateResourceTypeConfig(ctx context.Context, req RawConfig: configVal, } - for _, validateFunc := range r.ValidateResourceConfigFuncs { + for _, validateFunc := range r.ValidateRawResourceConfigFuncs { validateResp := &ValidateResourceConfigFuncResponse{} validateFunc(ctx, validateReq, validateResp) resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, validateResp.Diagnostics) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index e620c970737..954f23aef7e 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3514,7 +3514,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { }, expected: &tfprotov5.ValidateResourceTypeConfigResponse{}, }, - "Server without WriteOnlyAttributesAllowed capabilities: null WriteOnly attribute returns no errors": { + "Client without WriteOnlyAttributesAllowed capabilities: null WriteOnly attribute returns no errors": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test_resource": { @@ -3726,17 +3726,17 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { }, }, }, - "Server with ValidateResourceConfigFunc: WriteOnlyAttributesAllowed true returns diags": { + "Server with ValidateRawResourceConfigFunc: WriteOnlyAttributesAllowed true returns diags": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test_resource": { - ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { if req.WriteOnlyAttributesAllowed { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3746,7 +3746,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3790,26 +3790,26 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, }, }, }, - "Server with ValidateResourceConfigFunc: WriteOnlyAttributesAllowed false returns diags": { + "Server with ValidateRawResourceConfigFunc: WriteOnlyAttributesAllowed false returns diags": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test_resource": { - ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3819,7 +3819,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3859,20 +3859,20 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, }, }, }, - "Server with ValidateResourceConfigFunc: equal config value returns diags": { + "Server with ValidateRawResourceConfigFunc: equal config value returns diags": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ "test_resource": { - ValidateResourceConfigFuncs: []ValidateResourceConfigFunc{ + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { equals := req.RawConfig.Equals(cty.ObjectVal(map[string]cty.Value{ "id": cty.NullVal(cty.String), @@ -3883,7 +3883,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3898,7 +3898,7 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { resp.Diagnostics = diag.Diagnostics{ { Severity: diag.Error, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, } } @@ -3938,11 +3938,11 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "ValidateResourceConfigFunc Error", + Summary: "ValidateRawResourceConfigFunc Error", }, }, }, diff --git a/helper/schema/resource.go b/helper/schema/resource.go index dd52d1ca777..0d46c3aac87 100644 --- a/helper/schema/resource.go +++ b/helper/schema/resource.go @@ -645,15 +645,18 @@ type Resource struct { // interacting with this resource. ResourceBehavior ResourceBehavior - // ValidateResourceConfigFuncs allows functions to define arbitrary validation - // logic during the ValidateResourceTypeConfig RPC. ValidateResourceConfigFunc receives + // ValidateRawResourceConfigFuncs allows functions to define arbitrary validation + // logic during the ValidateResourceTypeConfig RPC. ValidateRawResourceConfigFunc receives // the client capabilities from the ValidateResourceTypeConfig RPC and the raw cty // config value for the entire resource before it is shimmed, and it can return error // diagnostics based on the inspection of those values. // - // ValidateResourceConfigFuncs is only valid for Managed Resource types and will not be + // ValidateRawResourceConfigFuncs is only valid for Managed Resource types and will not be // called for Data Resource or Block types. - ValidateResourceConfigFuncs []ValidateResourceConfigFunc + // + // Developers should prefer other validation methods first as this validation function + // deals with raw cty values. + ValidateRawResourceConfigFuncs []ValidateRawResourceConfigFunc } // ResourceBehavior controls SDK-specific logic when interacting @@ -680,10 +683,10 @@ type ProviderDeferredBehavior struct { EnablePlanModification bool } -// ValidateResourceConfigFunc is a function used to validate the raw resource config +// ValidateRawResourceConfigFunc is a function used to validate the raw resource config // and has Diagnostic support. it is only valid for Managed Resource types and will not be // called for Data Resource or Block types. -type ValidateResourceConfigFunc func(context.Context, ValidateResourceConfigFuncRequest, *ValidateResourceConfigFuncResponse) +type ValidateRawResourceConfigFunc func(context.Context, ValidateResourceConfigFuncRequest, *ValidateResourceConfigFuncResponse) type ValidateResourceConfigFuncRequest struct { // WriteOnlyAttributesAllowed indicates that the Terraform client diff --git a/helper/validation/path.go b/helper/validation/path.go index b17449a3d5a..b8707330d01 100644 --- a/helper/validation/path.go +++ b/helper/validation/path.go @@ -7,10 +7,10 @@ import ( "github.com/hashicorp/go-cty/cty" ) -// PathEquals compares two Paths for equality. For cty.IndexStep, +// PathMatches compares two Paths for equality. For cty.IndexStep, // unknown key values are treated as an Any qualifier and will // match any index step of the same type. -func PathEquals(p cty.Path, other cty.Path) bool { +func PathMatches(p cty.Path, other cty.Path) bool { if len(p) != len(other) { return false } diff --git a/helper/validation/path_test.go b/helper/validation/path_test.go index aabb2dc281a..b85b837ed68 100644 --- a/helper/validation/path_test.go +++ b/helper/validation/path_test.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/go-cty/cty" ) -func TestPathEquals(t *testing.T) { +func TestPathMatches(t *testing.T) { tests := map[string]struct { p cty.Path other cty.Path @@ -108,8 +108,8 @@ func TestPathEquals(t *testing.T) { } for name, tc := range tests { t.Run(name, func(t *testing.T) { - if got := PathEquals(tc.p, tc.other); got != tc.want { - t.Errorf("PathEquals() = %v, want %v", got, tc.want) + if got := PathMatches(tc.p, tc.other); got != tc.want { + t.Errorf("PathMatches() = %v, want %v", got, tc.want) } }) } diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go index 1fa9636814f..23832d5ae45 100644 --- a/helper/validation/write_only.go +++ b/helper/validation/write_only.go @@ -13,7 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" ) -// PreferWriteOnlyAttribute is a ValidateResourceConfigFunc that returns a warning +// PreferWriteOnlyAttribute is a ValidateRawResourceConfigFunc that returns a warning // if the Terraform client supports write-only attributes and the old attribute is // not null. // The last step in the path must be a cty.GetAttrStep{}. @@ -22,7 +22,7 @@ import ( // For lists: cty.Index(cty.UnknownVal(cty.Number)), // For maps: cty.Index(cty.UnknownVal(cty.String)), // For sets: cty.Index(cty.UnknownVal(cty.Object(nil))), -func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName string) schema.ValidateResourceConfigFunc { +func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName string) schema.ValidateRawResourceConfigFunc { return func(ctx context.Context, req schema.ValidateResourceConfigFuncRequest, resp *schema.ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { return @@ -31,7 +31,7 @@ func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName stri var oldAttrs []attribute err := cty.Walk(req.RawConfig, func(path cty.Path, value cty.Value) (bool, error) { - if PathEquals(path, oldAttribute) { + if PathMatches(path, oldAttribute) { oldAttrs = append(oldAttrs, attribute{ value: value, path: path, From d3a4926810204f5389d637f091aed1f5e7566c05 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 15:33:35 -0400 Subject: [PATCH 20/58] Add clarifying comments. --- helper/schema/schema.go | 6 +++++- internal/configs/configschema/schema.go | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index ce58deef24d..80a3d8ecb57 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -399,10 +399,12 @@ type Schema struct { // WriteOnly indicates that the practitioner can choose a value for this // attribute, but Terraform will not store this attribute in state. // If WriteOnly is true, either Optional or Required must also be true. + // If an attribute is Required and WriteOnly, an attribute value + // is only required on resource creation. // // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. // - // This functionality is only supported in Terraform 1.XX and later. TODO: Add Terraform version + // This functionality is only supported in Terraform 1.11 and later. // Practitioners that choose a value for this attribute with older // versions of Terraform will receive an error. WriteOnly bool @@ -1725,6 +1727,8 @@ func (m schemaMap) validate( } if !ok { + // We don't validate required + writeOnly attributes here + // as that is done in PlanResourceChange (only on create). if schema.Required && !schema.WriteOnly { return append(diags, diag.Diagnostic{ Severity: diag.Error, diff --git a/internal/configs/configschema/schema.go b/internal/configs/configschema/schema.go index a45c5cc241d..bf29acab63d 100644 --- a/internal/configs/configschema/schema.go +++ b/internal/configs/configschema/schema.go @@ -87,10 +87,12 @@ type Attribute struct { // WriteOnly indicates that the practitioner can choose a value for this // attribute, but Terraform will not store this attribute in state. // If WriteOnly is true, either Optional or Required must also be true. + // If an attribute is Required and WriteOnly, an attribute value + // is only required on resource creation. // // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. // - // This functionality is only supported in Terraform 1.XX and later. TODO: add Terraform version + // This functionality is only supported in Terraform 1.11 and later. // Practitioners that choose a value for this attribute with older // versions of Terraform will receive an error. WriteOnly bool From 437577d4f37f14cbb660218efecbe01269d63a55 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 23 Sep 2024 15:47:55 -0400 Subject: [PATCH 21/58] Add internal validation preventing data sources from defining `ValidateRawResourceConfigFuncs` --- helper/schema/provider.go | 4 +++ helper/schema/provider_test.go | 59 ++++++++++++++++++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/helper/schema/provider.go b/helper/schema/provider.go index 498d0f55429..d60e2d3764e 100644 --- a/helper/schema/provider.go +++ b/helper/schema/provider.go @@ -228,6 +228,10 @@ func (p *Provider) InternalValidate() error { validationErrors = append(validationErrors, fmt.Errorf("data source %s: %s", k, err)) } + if len(r.ValidateRawResourceConfigFuncs) > 0 { + validationErrors = append(validationErrors, fmt.Errorf("data source %s cannot contain ValidateRawResourceConfigFuncs", k)) + } + dataSourceSchema := schemaMap(r.SchemaMap()) if dataSourceSchema.hasWriteOnly() { validationErrors = append(validationErrors, fmt.Errorf("data source %s cannot contain WriteOnly attributes", k)) diff --git a/helper/schema/provider_test.go b/helper/schema/provider_test.go index 00de0894272..e43c8878fe8 100644 --- a/helper/schema/provider_test.go +++ b/helper/schema/provider_test.go @@ -2420,6 +2420,65 @@ func TestProvider_InternalValidate(t *testing.T) { }, ExpectedErr: nil, }, + "Data source with ValidateRawResourceConfigFuncs returns an error": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + DataSourcesMap: map[string]*Resource{ + "data-foo": { + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + }, + }, + }, + ExpectedErr: fmt.Errorf("data source data-foo cannot contain ValidateRawResourceConfigFuncs"), + }, + "Resource with ValidateRawResourceConfigFuncs returns no errors": { + P: &Provider{ + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + }, + }, + ResourcesMap: map[string]*Resource{ + "resource-foo": { + ValidateRawResourceConfigFuncs: []ValidateRawResourceConfigFunc{ + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + + }, + func(ctx context.Context, req ValidateResourceConfigFuncRequest, resp *ValidateResourceConfigFuncResponse) { + + }, + }, + Schema: map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + }, + }, + }, + ExpectedErr: nil, + }, } for name, tc := range cases { From d5a7bd68ffad265abd9557f57800526084c18a82 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 3 Oct 2024 14:18:55 -0400 Subject: [PATCH 22/58] Change `writeOnlyAttributeName` parameter to use `cty.Path` --- helper/validation/write_only.go | 42 +++++++++++++++++++++++----- helper/validation/write_only_test.go | 9 ++++-- 2 files changed, 41 insertions(+), 10 deletions(-) diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go index 23832d5ae45..78b03ec23cf 100644 --- a/helper/validation/write_only.go +++ b/helper/validation/write_only.go @@ -22,12 +22,37 @@ import ( // For lists: cty.Index(cty.UnknownVal(cty.Number)), // For maps: cty.Index(cty.UnknownVal(cty.String)), // For sets: cty.Index(cty.UnknownVal(cty.Object(nil))), -func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName string) schema.ValidateRawResourceConfigFunc { +func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttribute cty.Path) schema.ValidateRawResourceConfigFunc { return func(ctx context.Context, req schema.ValidateResourceConfigFuncRequest, resp *schema.ValidateResourceConfigFuncResponse) { if !req.WriteOnlyAttributesAllowed { return } + pathLen := len(writeOnlyAttribute) + + if pathLen == 0 { + return + } + + lastStep := writeOnlyAttribute[pathLen-1] + + // Only attribute steps have a Name field + writeOnlyAttrStep, ok := lastStep.(cty.GetAttrStep) + if !ok { + resp.Diagnostics = diag.Diagnostics{ + { + Severity: diag.Error, + Summary: "Invalid writeOnlyAttribute path", + Detail: "The Terraform Provider unexpectedly provided a path that does not match the current schema. " + + "This can happen if the path does not correctly follow the schema in structure or types. " + + "Please report this to the provider developers. \n\n" + + "The writeOnlyAttribute path provided is invalid. The last step in the path must be a cty.GetAttrStep{}", + AttributePath: writeOnlyAttribute, + }, + } + return + } + var oldAttrs []attribute err := cty.Walk(req.RawConfig, func(path cty.Path, value cty.Value) (bool, error) { @@ -47,22 +72,25 @@ func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName stri for _, attr := range oldAttrs { attrPath := attr.path.Copy() - pathLen := len(attrPath) + pathLen = len(attrPath) if pathLen == 0 { return } - lastStep := attrPath[pathLen-1] + lastStep = attrPath[pathLen-1] // Only attribute steps have a Name field attrStep, ok := lastStep.(cty.GetAttrStep) if !ok { resp.Diagnostics = diag.Diagnostics{ { - Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "The specified oldAttribute path must point to an attribute.", + Severity: diag.Error, + Summary: "Invalid oldAttribute path", + Detail: "The Terraform Provider unexpectedly provided a path that does not match the current schema. " + + "This can happen if the path does not correctly follow the schema in structure or types. " + + "Please report this to the provider developers. \n\n" + + "The oldAttribute path provided is invalid. The last step in the path must be a cty.GetAttrStep{}", AttributePath: attrPath, }, } @@ -74,7 +102,7 @@ func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttributeName stri Severity: diag.Warning, Summary: "Available Write-Only Attribute Alternative", Detail: fmt.Sprintf("The attribute %s has a WriteOnly version %s available. "+ - "Use the WriteOnly version of the attribute when possible.", attrStep.Name, writeOnlyAttributeName), + "Use the WriteOnly version of the attribute when possible.", attrStep.Name, writeOnlyAttrStep.Name), AttributePath: attr.path, }) } diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go index 8adf6d70f4e..b4dd2445651 100644 --- a/helper/validation/write_only_test.go +++ b/helper/validation/write_only_test.go @@ -45,8 +45,11 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { expectedDiags: diag.Diagnostics{ { Severity: diag.Error, - Summary: "Invalid oldAttributePath", - Detail: "The specified oldAttribute path must point to an attribute.", + Summary: "Invalid oldAttribute path", + Detail: "The Terraform Provider unexpectedly provided a path that does not match the current schema. " + + "This can happen if the path does not correctly follow the schema in structure or types. " + + "Please report this to the provider developers. \n\n" + + "The oldAttribute path provided is invalid. The last step in the path must be a cty.GetAttrStep{}", AttributePath: cty.Path{ cty.GetAttrStep{Name: "oldAttribute"}, cty.IndexStep{ @@ -752,7 +755,7 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { for name, tc := range cases { t.Run(name, func(t *testing.T) { - f := PreferWriteOnlyAttribute(tc.oldAttributePath, "writeOnlyAttribute") + f := PreferWriteOnlyAttribute(tc.oldAttributePath, cty.GetAttrPath("writeOnlyAttribute")) actual := &schema.ValidateResourceConfigFuncResponse{} f(context.Background(), tc.validateConfigReq, actual) From 5c2e2e2343ac2fb10903fbfb2227eefb6820bb21 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Thu, 3 Oct 2024 14:24:21 -0400 Subject: [PATCH 23/58] Simplify validation condition logic --- helper/schema/schema.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 80a3d8ecb57..5076cd5d60d 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -851,7 +851,7 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro return fmt.Errorf("%s: One of optional, required, or computed must be set", k) } - if v.WriteOnly && !(v.Required || v.Optional) { + if v.WriteOnly && v.Required && v.Optional { return fmt.Errorf("%s: WriteOnly must be set with either Required or Optional", k) } From a5d8c2adad4396f5de430c83999cb9c3b1c0de2d Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 14:02:41 -0500 Subject: [PATCH 24/58] run `go mod tidy` --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index c2bed6ac1f9..54a6eb151bc 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.23.0 - github.com/hashicorp/terraform-plugin-go v0.25.0 + github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/go-testing-interface v1.14.1 diff --git a/go.sum b/go.sum index 68a66d17058..3879568283d 100644 --- a/go.sum +++ b/go.sum @@ -74,8 +74,8 @@ github.com/hashicorp/terraform-exec v0.21.0 h1:uNkLAe95ey5Uux6KJdua6+cv8asgILFVW github.com/hashicorp/terraform-exec v0.21.0/go.mod h1:1PPeMYou+KDUSSeRE9szMZ/oHf4fYUmB923Wzbq1ICg= github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2xoR+lppBkI= github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= -github.com/hashicorp/terraform-plugin-go v0.25.0 h1:oi13cx7xXA6QciMcpcFi/rwA974rdTxjqEhXJjbAyks= -github.com/hashicorp/terraform-plugin-go v0.25.0/go.mod h1:+SYagMYadJP86Kvn+TGeV+ofr/R3g4/If0O5sO96MVw= +github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 h1:IrFJDqDFuJ7Soy0n/M2oiAn/oH9Cvfi+MMKJh1s2M6I= +github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4/go.mod h1:zoSM9LyEFI4iVkDeKXgwBHV0uTuIIXydtK1fy9J4wBA= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= From 3f32220ede4409c3770cc5bcec434bcf30b67fe6 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 14:40:04 -0500 Subject: [PATCH 25/58] update `terraform-plugin-go` dependency --- go.mod | 4 ++-- go.sum | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index 54a6eb151bc..3c6f5caafa3 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/terraform-exec v0.21.0 github.com/hashicorp/terraform-json v0.23.0 - github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 + github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/mitchellh/copystructure v1.2.0 github.com/mitchellh/go-testing-interface v1.14.1 @@ -57,5 +57,5 @@ require ( google.golang.org/appengine v1.6.8 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240814211410-ddb44dafa142 // indirect google.golang.org/grpc v1.67.1 // indirect - google.golang.org/protobuf v1.35.1 // indirect + google.golang.org/protobuf v1.35.2 // indirect ) diff --git a/go.sum b/go.sum index 3879568283d..3dafba24a45 100644 --- a/go.sum +++ b/go.sum @@ -76,6 +76,8 @@ github.com/hashicorp/terraform-json v0.23.0 h1:sniCkExU4iKtTADReHzACkk8fnpQXrdD2 github.com/hashicorp/terraform-json v0.23.0/go.mod h1:MHdXbBAbSg0GvzuWazEGKAn/cyNfIB7mN6y7KJN6y2c= github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4 h1:IrFJDqDFuJ7Soy0n/M2oiAn/oH9Cvfi+MMKJh1s2M6I= github.com/hashicorp/terraform-plugin-go v0.24.1-0.20240926180758-adf4d559d7d4/go.mod h1:zoSM9LyEFI4iVkDeKXgwBHV0uTuIIXydtK1fy9J4wBA= +github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0 h1:92SnqDgZi6TgHrJlbP5UicGWgoZO+QQUrZyzwW2Ztqs= +github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241125193751-9019c19499e0/go.mod h1:f8P2pHGkZrtdKLpCI2qIvrewUY+c4nTvtayqjJR9IcY= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-registry-address v0.2.3 h1:2TAiKJ1A3MAkZlH1YI/aTVcLZRu7JseiXNRHbOAyoTI= @@ -203,6 +205,7 @@ google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp0 google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= From 28a57689ab6174a4a6bace1d459103f55faf74dc Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 25 Nov 2024 19:02:38 -0500 Subject: [PATCH 26/58] Add write-only support to `ProtoToConfigSchema()` --- internal/plugin/convert/schema.go | 2 ++ internal/plugin/convert/schema_test.go | 12 ++++++++++++ 2 files changed, 14 insertions(+) diff --git a/internal/plugin/convert/schema.go b/internal/plugin/convert/schema.go index e2b4e431ce9..a02aaec0078 100644 --- a/internal/plugin/convert/schema.go +++ b/internal/plugin/convert/schema.go @@ -12,6 +12,7 @@ import ( "github.com/hashicorp/go-cty/cty" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" "github.com/hashicorp/terraform-plugin-sdk/v2/internal/logging" ) @@ -151,6 +152,7 @@ func ConfigSchemaToProto(ctx context.Context, b *configschema.Block) *tfprotov5. Required: a.Required, Sensitive: a.Sensitive, Deprecated: a.Deprecated, + WriteOnly: a.WriteOnly, } var err error diff --git a/internal/plugin/convert/schema_test.go b/internal/plugin/convert/schema_test.go index cf8b17aded6..993fb476948 100644 --- a/internal/plugin/convert/schema_test.go +++ b/internal/plugin/convert/schema_test.go @@ -13,6 +13,7 @@ import ( "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tftypes" + "github.com/hashicorp/terraform-plugin-sdk/v2/internal/configs/configschema" ) @@ -232,6 +233,12 @@ func TestConvertProtoSchemaBlocks(t *testing.T) { Type: tftypes.Number, Required: true, }, + { + Name: "write-only", + Type: tftypes.String, + WriteOnly: true, + Optional: true, + }, }, }, &configschema.Block{ @@ -253,6 +260,11 @@ func TestConvertProtoSchemaBlocks(t *testing.T) { Type: cty.Number, Required: true, }, + "write-only": { + Type: cty.String, + WriteOnly: true, + Optional: true, + }, }, }, }, From 2aec830304b5840f2ea183fd0949d0154b36a776 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 3 Dec 2024 16:36:54 -0500 Subject: [PATCH 27/58] 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 2b328f1ad27..89e3990eea3 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 1737ca775a7..90eefe9cc29 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/58] 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 89e3990eea3..914bc947d1b 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 68ec2198257..bd81772d76c 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 56306a190f8..714a823e810 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 c9f71081d35..cc3776abc9a 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 a87db12cd89..37d73de8fe1 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 03889af6158..3a8884b39f5 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 3b4179b4b3b..867089b9fb2 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/58] 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 914bc947d1b..89e3990eea3 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 bd81772d76c..68ec2198257 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 714a823e810..56306a190f8 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 cc3776abc9a..c9f71081d35 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 37d73de8fe1..a87db12cd89 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 3a8884b39f5..03889af6158 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 867089b9fb2..3b4179b4b3b 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/58] 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 68ec2198257..9cb5267424e 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 56306a190f8..daabea396c6 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 c9f71081d35..4bfe234ee0d 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/58] 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 89e3990eea3..4d7d223c9c2 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/58] 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 9cb5267424e..2606e13c64e 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 daabea396c6..75a1c0c0528 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 4bfe234ee0d..6e375030ed2 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/58] 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 3c6f5caafa3..473a4835483 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 3dafba24a45..c6c7c3d0988 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/58] 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 2606e13c64e..6d8a81b06a7 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/58] 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 4d7d223c9c2..0b321c70d2f 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 a87db12cd89..c14862a417a 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 03889af6158..2aefbeab960 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/58] 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 2aefbeab960..046e6a7aefa 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/58] 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 75a1c0c0528..5d15abe4884 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 6e375030ed2..5210218116e 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/58] 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 90eefe9cc29..c92ec30ec93 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 ebe2bcc0801..a178e43a47b 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/58] 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 6af9490b9e0..404bad49f33 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/58] 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 d9a84676f4b..d0ba189c0b3 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) + } + }) + } +} From c0d2bac4e187563113a7a0836adef08fb7596c32 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 3 Jan 2025 16:21:34 -0500 Subject: [PATCH 41/58] Add validation error for `WriteOnly` and `ForceNew` --- helper/schema/schema.go | 5 +++++ helper/schema/schema_test.go | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index c92ec30ec93..60893e02dc0 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -399,6 +399,7 @@ 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. + // WriteOnly cannot be set with ForceNew. // If an attribute is Required and WriteOnly, an attribute value // is only required on resource creation. // @@ -859,6 +860,10 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro return fmt.Errorf("%s: WriteOnly cannot be set with Computed", k) } + if v.WriteOnly && v.ForceNew { + return fmt.Errorf("%s: WriteOnly cannot be set with ForceNew", k) + } + computedOnly := v.Computed && !v.Optional switch v.ConfigMode { diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index a178e43a47b..6eeda512fae 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -5086,6 +5086,18 @@ func TestSchemaMap_InternalValidate(t *testing.T) { true, }, + "Attribute with WriteOnly and ForceNew set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + ForceNew: true, + Optional: true, + WriteOnly: true, + }, + }, + true, + }, + "Attribute with WriteOnly, Optional, and Computed set returns error": { map[string]*Schema{ "foo": { From 1dcc224b4c3d8b382561e4d6e7a377ed93f6d689 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 14 Jan 2025 13:58:25 -0500 Subject: [PATCH 42/58] Add write-only value nullification to `ImportResourceState` and `UpgradeResourceState` RPCs --- helper/schema/grpc_provider.go | 6 ++ helper/schema/grpc_provider_test.go | 142 ++++++++++++++++++++++++++++ 2 files changed, 148 insertions(+) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index 0b321c70d2f..ae4d08e5a36 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -420,6 +420,9 @@ func (s *GRPCProviderServer) UpgradeResourceState(ctx context.Context, req *tfpr // Normalize the value and fill in any missing blocks. val = objchange.NormalizeObjectFromLegacySDK(val, schemaBlock) + // Set any write-only attribute values to null + val = setWriteOnlyNullValues(val, schemaBlock) + // encode the final state to the expected msgpack format newStateMP, err := msgpack.Marshal(val, schemaBlock.ImpliedType()) if err != nil { @@ -1347,6 +1350,9 @@ func (s *GRPCProviderServer) ImportResourceState(ctx context.Context, req *tfpro newStateVal = cty.ObjectVal(newStateValueMap) } + // Set any write-only attribute values to null + newStateVal = setWriteOnlyNullValues(newStateVal, schemaBlock) + newStateMP, err := msgpack.Marshal(newStateVal, schemaBlock.ImpliedType()) if err != nil { resp.Diagnostics = convert.AppendProtoDiag(ctx, resp.Diagnostics, err) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index 6d8a81b06a7..c92d88bfbb6 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -4519,6 +4519,94 @@ func TestUpgradeState_flatmapStateMissingMigrateState(t *testing.T) { } } +func TestUpgradeState_writeOnlyNullification(t *testing.T) { + r := &Resource{ + SchemaVersion: 2, + Schema: map[string]*Schema{ + "two": { + Type: TypeInt, + Optional: true, + WriteOnly: true, + }, + }, + } + + r.StateUpgraders = []StateUpgrader{ + { + Version: 0, + Type: cty.Object(map[string]cty.Type{ + "id": cty.String, + "zero": cty.Number, + }), + Upgrade: func(ctx context.Context, m map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + _, ok := m["zero"].(float64) + if !ok { + return nil, fmt.Errorf("zero not found in %#v", m) + } + m["one"] = float64(1) + delete(m, "zero") + return m, nil + }, + }, + { + Version: 1, + Type: cty.Object(map[string]cty.Type{ + "id": cty.String, + "one": cty.Number, + }), + Upgrade: func(ctx context.Context, m map[string]interface{}, meta interface{}) (map[string]interface{}, error) { + _, ok := m["one"].(float64) + if !ok { + return nil, fmt.Errorf("one not found in %#v", m) + } + m["two"] = float64(2) + delete(m, "one") + return m, nil + }, + }, + } + + server := NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": r, + }, + }) + + req := &tfprotov5.UpgradeResourceStateRequest{ + TypeName: "test", + Version: 0, + RawState: &tfprotov5.RawState{ + JSON: []byte(`{"id":"bar","zero":0}`), + }, + } + + resp, err := server.UpgradeResourceState(context.Background(), req) + if err != nil { + t.Fatal(err) + } + + if len(resp.Diagnostics) > 0 { + for _, d := range resp.Diagnostics { + t.Errorf("%#v", d) + } + t.Fatal("error") + } + + val, err := msgpack.Unmarshal(resp.UpgradedState.MsgPack, r.CoreConfigSchema().ImpliedType()) + if err != nil { + t.Fatal(err) + } + + expected := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("bar"), + "two": cty.NullVal(cty.Number), + }) + + if !cmp.Equal(expected, val, valueComparer, equateEmpty) { + t.Fatal(cmp.Diff(expected, val, valueComparer, equateEmpty)) + } +} + func TestReadResource(t *testing.T) { t.Parallel() @@ -6631,6 +6719,60 @@ func TestImportResourceState(t *testing.T) { }, }, }, + "write-only-nullification": { + server: NewGRPCProviderServer(&Provider{ + ResourcesMap: map[string]*Resource{ + "test": { + SchemaVersion: 1, + Schema: map[string]*Schema{ + "id": { + Type: TypeString, + Required: true, + }, + "test_string": { + Type: TypeString, + Optional: true, + WriteOnly: true, + }, + }, + Importer: &ResourceImporter{ + StateContext: func(ctx context.Context, d *ResourceData, meta interface{}) ([]*ResourceData, error) { + err := d.Set("test_string", "new-imported-val") + if err != nil { + return nil, err + } + + return []*ResourceData{d}, nil + }, + }, + }, + }, + }), + req: &tfprotov5.ImportResourceStateRequest{ + TypeName: "test", + ID: "imported-id", + }, + expected: &tfprotov5.ImportResourceStateResponse{ + ImportedResources: []*tfprotov5.ImportedResource{ + { + TypeName: "test", + State: &tfprotov5.DynamicValue{ + MsgPack: mustMsgpackMarshal( + cty.Object(map[string]cty.Type{ + "id": cty.String, + "test_string": cty.String, + }), + cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("imported-id"), + "test_string": cty.NullVal(cty.String), + }), + ), + }, + Private: []byte(`{"schema_version":"1"}`), + }, + }, + }, + }, } for name, testCase := range testCases { From f831b07bf67f834a7999a8bcef518c8139158cac Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 15 Jan 2025 12:40:39 -0500 Subject: [PATCH 43/58] Move `Required` + `WriteOnly` attribute validation to `ValidateResourceTypeConfig` RPC --- helper/schema/grpc_provider.go | 10 - helper/schema/grpc_provider_test.go | 66 --- helper/schema/schema.go | 10 +- helper/schema/schema_test.go | 6 +- helper/schema/write_only.go | 94 ---- helper/schema/write_only_test.go | 556 ------------------------ internal/configs/configschema/schema.go | 5 +- 7 files changed, 9 insertions(+), 738 deletions(-) diff --git a/helper/schema/grpc_provider.go b/helper/schema/grpc_provider.go index ae4d08e5a36..f942814806c 100644 --- a/helper/schema/grpc_provider.go +++ b/helper/schema/grpc_provider.go @@ -850,16 +850,6 @@ 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(configVal, schemaBlock, cty.Path{}) - 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) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index c92d88bfbb6..ff90044d433 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -5195,72 +5195,6 @@ func TestPlanResourceChange(t *testing.T) { UnsafeToUseLegacyTypeSystem: true, }, }, - "create: write-only, required attribute with null value throws error": { - 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", - 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 resource contains a null value for Required WriteOnly attribute \"foo\"", - Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), - }, - }, - UnsafeToUseLegacyTypeSystem: true, - }, - }, "create: write-only value can be retrieved in CustomizeDiff": { server: NewGRPCProviderServer(&Provider{ ResourcesMap: map[string]*Resource{ diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 60893e02dc0..215eb8b9b95 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -398,10 +398,8 @@ 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. - // WriteOnly cannot be set with ForceNew. - // If an attribute is Required and WriteOnly, an attribute value - // is only required on resource creation. + // WriteOnly can only be set for managed resource schemas. If WriteOnly is true, + // either Optional or Required must also be true. WriteOnly cannot be set with ForceNew. // // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. // @@ -1736,9 +1734,7 @@ 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 { + if schema.Required { 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 6eeda512fae..a8dcc094947 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -7088,7 +7088,7 @@ func TestSchemaMap_Validate(t *testing.T) { }, }, }, - "Required + WriteOnly attribute with null value returns no errors": { + "Required + WriteOnly attribute with null value returns validation error": { Schema: map[string]*Schema{ "write_only_attribute": { Type: TypeString, @@ -7098,6 +7098,7 @@ func TestSchemaMap_Validate(t *testing.T) { }, Config: nil, + Err: true, }, "Required + WriteOnly attribute with default func returns no errors": { Schema: map[string]*Schema{ @@ -7111,7 +7112,7 @@ func TestSchemaMap_Validate(t *testing.T) { Config: nil, }, - "Required + WriteOnly attribute with default func nil value returns no errors": { + "Required + WriteOnly attribute with default func nil value returns validation error": { Schema: map[string]*Schema{ "write_only_attribute": { Type: TypeString, @@ -7122,6 +7123,7 @@ func TestSchemaMap_Validate(t *testing.T) { }, Config: nil, + Err: true, }, } diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index c14862a417a..48b8d9f60f7 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -212,97 +212,3 @@ func validateWriteOnlyNullValues(val cty.Value, schema *configschema.Block, path 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(val cty.Value, schema *configschema.Block, path cty.Path) diag.Diagnostics { - if !val.IsKnown() || val.IsNull() { - return diag.Diagnostics{} - } - - 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 resource contains a null value for Required WriteOnly attribute %q", name), - AttributePath: append(path, cty.GetAttrStep{Name: name}), - }) - } - } - - 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. - 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(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(v, &blockS.Block, setBlockPath)...) - } - - case blockValType.IsListType(), blockValType.IsTupleType(): - listVals := blockVal.AsValueSlice() - - for i, v := range listVals { - listBlockPath := append(blockPath, cty.IndexStep{ - Key: cty.NumberIntVal(int64(i)), - }) - diags = append(diags, validateWriteOnlyRequiredValues(v, &blockS.Block, listBlockPath)...) - } - - case blockValType.IsMapType(), blockValType.IsObjectType(): - mapVals := blockVal.AsValueMap() - - for k, v := range mapVals { - mapBlockPath := append(blockPath, cty.IndexStep{ - Key: cty.StringVal(k), - }) - diags = append(diags, validateWriteOnlyRequiredValues(v, &blockS.Block, mapBlockPath)...) - } - - default: - panic(fmt.Sprintf("failed to validate WriteOnly values for nested block %q:%#v", name, blockValType)) - } - } - - return diags -} diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index 046e6a7aefa..f594981eb97 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -923,562 +923,6 @@ func Test_validateWriteOnlyNullValues(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 returns no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "required_write_only_attribute1": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "required_write_only_attribute2": { - 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{ - "required_write_only_block_attribute1": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - "required_write_only_block_attribute2": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "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{}, - }, - "All Optional + WriteOnly with null values returns no diags": { - &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "optional_write_only_attribute1": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "optional_write_only_attribute2": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "nested_block": { - Nesting: configschema.NestingSingle, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "optional_write_only_block_attribute1": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - "optional_write_only_block_attribute2": { - Type: cty.String, - Optional: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.NullVal(cty.Object(map[string]cty.Type{ - "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 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{ - "set_block": { - Nesting: configschema.NestingSet, - Block: configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "required_write_only_block_attribute": { - Type: cty.String, - Required: true, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "required_write_only_attribute": cty.NullVal(cty.String), - "set_block": cty.SetVal([]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 \"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"}, - }, - }, - }, - }, - "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{ - "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{ - "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 \"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"}, - }, - }, - }, - }, - "Map nested block, Required + WriteOnly attribute with null value returns diag": { - &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, - }, - "required_write_only_block_attribute": { - Type: cty.String, - Required: true, - WriteOnly: 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), - "required_write_only_block_attribute": cty.StringVal("boop"), - }), - "b": cty.ObjectVal(map[string]cty.Value{ - "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 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, Required + WriteOnly attribute with null returns diag": { - &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, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "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 \"write_only_block_attribute\"", - AttributePath: cty.Path{ - cty.GetAttrStep{Name: "nested_block"}, - cty.GetAttrStep{Name: "write_only_block_attribute"}, - }, - }, - }, - }, - "Set nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "set_block": { - Nesting: configschema.NestingSet, - 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, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "set_block": cty.SetVal([]cty.Value{ - cty.ObjectVal(map[string]cty.Value{ - "optional_write_only_block_attribute": cty.StringVal("blep"), - "required_write_only_block_attribute": cty.NullVal(cty.String), - }), - cty.ObjectVal(map[string]cty.Value{ - "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 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"}, - }, - }, - }, - }, - "List nested block, WriteOnly attribute with multiple values returns multiple diags": { - &configschema.Block{ - BlockTypes: map[string]*configschema.NestedBlock{ - "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, - WriteOnly: true, - }, - }, - }, - }, - }, - }, - cty.ObjectVal(map[string]cty.Value{ - "map_block": cty.MapVal(map[string]cty.Value{ - "a": cty.ObjectVal(map[string]cty.Value{ - "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_write_only_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 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(tc.Val, tc.Schema, cty.Path{}) - - if diff := cmp.Diff(got, tc.Expected, - cmp.AllowUnexported(cty.GetAttrStep{}, cty.IndexStep{}), - cmp.Comparer(indexStepComparer)); diff != "" { - t.Errorf("unexpected difference: %s", diff) - } - }) - } -} - func indexStepComparer(step cty.IndexStep, other cty.IndexStep) bool { return true } diff --git a/internal/configs/configschema/schema.go b/internal/configs/configschema/schema.go index 6042ac45b88..97d91b699eb 100644 --- a/internal/configs/configschema/schema.go +++ b/internal/configs/configschema/schema.go @@ -86,9 +86,8 @@ 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 can only be set for managed resource schemas. If WriteOnly is true, + // either Optional or Required must also be true. WriteOnly cannot be set with ForceNew. // // WriteOnly cannot be set to true for TypeList, TypeMap, or TypeSet. // From 511005e888d6fc119968702ecf42fee068c8b81a Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 21 Jan 2025 16:44:27 -0500 Subject: [PATCH 44/58] Add website documentation --- website/data/plugin-sdkv2-nav-data.json | 4 + website/docs/plugin/sdkv2/resources/index.mdx | 9 + .../sdkv2/resources/write-only-attributes.mdx | 177 ++++++++++++++++++ .../plugin/sdkv2/schemas/schema-behaviors.mdx | 45 +++++ 4 files changed, 235 insertions(+) create mode 100644 website/docs/plugin/sdkv2/resources/write-only-attributes.mdx diff --git a/website/data/plugin-sdkv2-nav-data.json b/website/data/plugin-sdkv2-nav-data.json index 86afe647a00..49fd9a2ae55 100644 --- a/website/data/plugin-sdkv2-nav-data.json +++ b/website/data/plugin-sdkv2-nav-data.json @@ -38,6 +38,10 @@ { "title": "State Migration", "path": "resources/state-migration" + }, + { + "title": "Write-only Attributes", + "path": "resources/write-only-attributes" } ] }, diff --git a/website/docs/plugin/sdkv2/resources/index.mdx b/website/docs/plugin/sdkv2/resources/index.mdx index 4202a983f84..aa9f24bf121 100644 --- a/website/docs/plugin/sdkv2/resources/index.mdx +++ b/website/docs/plugin/sdkv2/resources/index.mdx @@ -30,3 +30,12 @@ Terraform has data consistency rules for resources, which may not be easily disc Resources define the data types and API interactions required to create, update, and destroy infrastructure with a cloud vendor, while the [Terraform state](/terraform/language/state) stores mapping and metadata information for those remote objects. When resource implementations change (due to bug fixes, improvements, or changes to the backend APIs Terraform interacts with), they can sometimes become incompatible with existing state. When this happens, a migration is needed for resources provisioned in the wild with old schema configurations. Terraform resources support migrating state values in these scenarios via [State Migration](/terraform/plugin/sdkv2/resources/state-migration). + +## Write-only Attributes + +~> **NOTE:** Write-only attributes are only supported in Terraform `v1.11` or higher + +Write-only attributes are a special type of resource attribute +whose values are not sent to Terraform and do not persist in the Terraform plan or state artifacts. +Write-only attributes can accept [ephemeral values](https://developer.hashicorp.com/terraform/language/resources/ephemeral). +The [Write-only attribute] page discusses how to create these attributes. \ No newline at end of file diff --git a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx new file mode 100644 index 00000000000..7de185642e8 --- /dev/null +++ b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx @@ -0,0 +1,177 @@ +--- +page_title: Resources - Write-only Attributes +description: Implementing write-only attributes within resources. +--- + +# Resources - Write-only Attributes + +~> **NOTE:** Write-only attributes are only supported in Terraform `v1.11` or higher + +Write-only attributes are managed resource attributes whose values are not saved to the Terraform plan or state artifacts. Write-only attributes +should be used to handle secret values that do not need to be persisted in Terraform state, such as passwords, API keys, etc. +The provider is expected to be the terminal point for an ephemeral value, +which should either use the value by making the appropriate change to the API or ignore the value. Write-only attributes can accept ephemeral values. + +## General Concepts + +The following are high level differences between regular attributes and write-only attributes: + +- Write-only attributes can accept ephemeral and non-ephemeral values + +- Write-only attribute values are only available in the configuration, and the prior state, planned state, and final state values for +write-only attributes should always be `null`. + - Provider developers do not need to “clean up” write-only attribute values after using them as the SDKv2 will handle the nullification of write-only attributes for all RPCs. + +- Any value that is set for a write-only attribute using `(*ResourceData).Set()` by the provider will be set to `null` by SDKv2 before the RPC response is sent to Terraform + +- Write-only attribute values on its own will never cause a Terraform diff. + - This is because the prior state value for a write-only attribute will always be `null` and the planned/final state value will also be `null`, therefore, it cannot produce a diff on its own. + - The one exception to this case is if the write-only attribute is added to `requires_replace` via [CustomizeDiff](/terraform/plugin/sdkv2/resources/customizing-differences), in that case, the write-only attribute will always cause a diff/trigger a resource recreation + +- Since write-only attributes can accept ephemeral values, write-only attribute configuration values are not expected to be consistent between plan and apply. + +## Schema Behavior + +**Schema example:** + +```go +"password_wo": { + Type: schema.TypeString, + Required: true, + WriteOnly: true, +}, +``` + +**Restrictions:** + +- Cannot be used in data source or provider schemas. +- Must be set with either `Required` is `true` or `Optional` is `true` +- Cannot be used when `Computed` is `true` +- Cannot be used with aggregate schema types (e.g. `typeMap`, `typeList`, `typeSet`) + +## Retrieving Write-only Values + +Write-only attribute values are only available in the raw resource configuration, you cannot retrieve it using `(*resourceData).Get()` like other attribute values. + +Use the `(*schema.ResourceData).GetRawConfigAt()` method to retrieve the raw config value. + +This method is an advanced method that uses the `Hashicorp/cty` library for its type system. + +```go +woVal, diags := d.GetRawConfigAt(cty.GetAttrPath("password_wo")) +``` + +### cty.Path + +`(*schema.ResourceData).GetRawConfigAt()` uses `cty.Path` to specify locations in the raw configuration. + +This is very similar to the `terraform-plugin-framework` [paths](https://developer.hashicorp.com/terraform/plugin/framework/handling-data/paths) or `terraform-plugin-testing` [json paths](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/tfjson-paths). + +All top level attributes or blocks can be referred to using `cty.GetAttrPath()` + +```go +cty.GetAttrPath("top_level_schema_attribute") +``` + +Maps or map nested blocks can be traversed using `IndexString()` + +```go +// Map traversal +cty.GetAttrPath("map_attribute").IndexString("key1") + + +// Map nested block traversal +cty.GetAttrPath("map_nested_block").IndexString("block1").getAttr("map_nested_block_attribute") + +``` + +Lists or list nested blocks can be traversed using `IndexInt()` + +```go +// List traversal +cty.GetAttrPath("list_attribute").IndexInt(0) + + +// List nested block traversal +cty.GetAttrPath("list_nested_block").IndexInt(1).getAttr("list_nested_block_attribute") +``` + +Sets or set nested blocks can be traversed using `Index()`. `Index()` takes in a `cty.Value` of the set element that you want to traverse into. + +```go +// Set traversal +cty.GetAttrPath("set_attribute").Index(cty.String("set_string_val")) + + +// Set nested block traversal +cty.GetAttrPath("set_nested_block"). +Index(cty.ObjectVal(map[string]cty.Value{"set_nested_block_attribute": cty.StringVal("valueA")})) +.GetAttr("set_nested_block_attribute") // returns path to cty.String("valueA") + +``` + +### cty.Value + +When working with `cty.Value`, you must always check the type of the value before converting it to a Go value or else the conversion will cause a panic. + +```go +// Check that the type is a cty.String before conversion +if !woVal.Type().Equals(cty.String) { + return errors.New("error retrieving write-only attribute: password_wo - retrieved config value is not a string") +} + +// Check if the value is not null +if !woVal.IsNull() { + // Now we can safely convert to a Go string + encryptedValue = woVal.AsString() +} +``` + +## PreferWriteOnly Validator + +`PreferWriteOnlyAttribute()` is a validator that takes a `cty.Path` to a regular attribute and a `cty.Path` to a write-only attribute. + +Use this validator when you have a write-only version of an existing attribute, and you want to encourage practitioners to use the write-only version whenever possible. + +The validator returns a warning if the Terraform client is 1.11 or above and the value to the regular attribute is non-null. + +Usage: + +```go +func resourceDbInstance() *schema.Resource { + return &schema.Resource{ + Create: resourceCreate, + Read: resourceRead, + Delete: resourceDelete, + Importer: &schema.ResourceImporter{ + State: resourceImport, + }, + Schema: //omitted for brevity + ValidateRawResourceConfigFuncs: []schema.ValidateRawResourceConfigFunc{ + validation.PreferWriteOnlyAttribute(cty.GetAttrPath("password"), cty.GetAttrPath("password_wo")), + }, + } +} +``` + +```hcl +resource "example_db_instance" "ex" { + username = "foo" + password = "bar" # returns a warning encouraging practitioners to use `password_wo` instead. +} +``` + +When using `cty.Path` to traverse into a nested block, use an unknown value 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)))`, + + +## Best Practices + +Since write-only attributes have no prior values, we cannot determine user intent with a write-only attribute alone. To determine when to use/not use a write-only attribute value in your provider, we recommend using other attributes in the provider. For example: + +- Pair write-only attributes with a regular attribute to “trigger” the use of the write-only attribute + - For example, a `password_wo` write-only attribute can be paired with a configured `password_wo_version` attribute. When the `password_wo_version` updates, the provider will send the `password_wo` value to the API. +- Use a keepers attribute (which is used in the [Random Provider](https://registry.terraform.io/providers/hashicorp/random/latest/docs#resource-keepers)) that will take in arbitrary key-pair values. Whenever there is a change to the `keepers` attribute, the provider will use the write-only attribute value. \ No newline at end of file diff --git a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx index 7b473577ffb..1945b3f5446 100644 --- a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx +++ b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx @@ -197,6 +197,51 @@ resource "example_instance" "ex" { } ``` +### WriteOnly + +~> **NOTE:** Write-only attributes are only supported in Terraform `v1.11` or higher + +**Data structure:** [bool](https://pkg.go.dev/builtin#bool) + +**Value:** `true` + +**Restrictions:** + +- Cannot be used in data source or provider schemas. +- Must be set with either `Required` is `true` or `Optional` is `true` +- Cannot be used when `Computed` is `true` +- Cannot be used when `ForceNew` is `true` +- Cannot be used with aggregate schema types (e.g. `typeMap`, `typeList`, `typeSet`) + +`WriteOnly` should be used for attributes that handle secret values that do not need to be persisted in Terraform plan or state, +such as passwords, API keys, etc. Write-only attribute values are not sent to Terraform and do not +persist in the Terraform plan or state artifacts. +Attributes marked +as `WriteOnly` can accept [ephemeral values](https://developer.hashicorp.com/terraform/language/resources/ephemeral). + +**Schema example:** + +```go +"password_wo": { + Type: schema.TypeString, + Required: true, + WriteOnly: true, +}, +``` + +**Configuration example:** + +```hcl +ephemeral "random_password" "ex_pass" { + length = 15 +} + +resource "example_db_instance" "ex" { + username = "foo" + password_wo = random_password.ex_pass.password +} +``` + ## Function Behaviors ### DiffSuppressFunc From b6c06cfd38049f1b1ca1634fa088bd3cbc2786e9 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Tue, 21 Jan 2025 17:06:29 -0500 Subject: [PATCH 45/58] Add changelog entries --- .changes/unreleased/FEATURES-20250121-165644.yaml | 6 ++++++ .changes/unreleased/FEATURES-20250121-170105.yaml | 6 ++++++ .changes/unreleased/NOTES-20250121-170545.yaml | 5 +++++ 3 files changed, 17 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20250121-165644.yaml create mode 100644 .changes/unreleased/FEATURES-20250121-170105.yaml create mode 100644 .changes/unreleased/NOTES-20250121-170545.yaml diff --git a/.changes/unreleased/FEATURES-20250121-165644.yaml b/.changes/unreleased/FEATURES-20250121-165644.yaml new file mode 100644 index 00000000000..95b0f03e04c --- /dev/null +++ b/.changes/unreleased/FEATURES-20250121-165644.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'helper/schema: Added `WriteOnly` schema behavior for managed resource schemas to indicate a write-only attribute. +Write-only attributes are managed resource attributes whose values are not saved to the Terraform plan or state artifacts.' +time: 2025-01-21T16:56:44.038893-05:00 +custom: + Issue: "1375" diff --git a/.changes/unreleased/FEATURES-20250121-170105.yaml b/.changes/unreleased/FEATURES-20250121-170105.yaml new file mode 100644 index 00000000000..900b07887ee --- /dev/null +++ b/.changes/unreleased/FEATURES-20250121-170105.yaml @@ -0,0 +1,6 @@ +kind: FEATURES +body: 'helper/validation: Added `PreferWriteOnlyAttribute()` validator that warns practitioners when a write-only version of +a configured attribute is available.' +time: 2025-01-21T17:01:05.40229-05:00 +custom: + Issue: "1375" diff --git a/.changes/unreleased/NOTES-20250121-170545.yaml b/.changes/unreleased/NOTES-20250121-170545.yaml new file mode 100644 index 00000000000..fb75dba49a3 --- /dev/null +++ b/.changes/unreleased/NOTES-20250121-170545.yaml @@ -0,0 +1,5 @@ +kind: NOTES +body: Write-only attribute support is in technical preview and offered without compatibility promises until Terraform 1.11 is generally available. +time: 2025-01-21T17:05:45.398836-05:00 +custom: + Issue: "1375" From 36978c5927783f3cbfb238a6434eae2371063991 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 22 Jan 2025 12:14:13 -0500 Subject: [PATCH 46/58] Update `terraform-plugin-go` dependency to `v0.24.0` --- go.mod | 12 ++++++------ go.sum | 40 ++++++++++++++++++++++++++++------------ 2 files changed, 34 insertions(+), 18 deletions(-) diff --git a/go.mod b/go.mod index 2fffc6e2e35..ade46fadc70 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.24.0 - github.com/hashicorp/terraform-plugin-go v0.25.1-0.20241217173851-dcf8f64dbfaa + github.com/hashicorp/terraform-plugin-go v0.26.0 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 @@ -38,7 +38,7 @@ require ( github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect - github.com/hashicorp/terraform-registry-address v0.2.3 // indirect + github.com/hashicorp/terraform-registry-address v0.2.4 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect github.com/hashicorp/yamux v0.1.1 // indirect github.com/mattn/go-colorable v0.1.13 // indirect @@ -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.22.0 // indirect - golang.org/x/net v0.33.0 // indirect + golang.org/x/net v0.34.0 // indirect golang.org/x/sync v0.10.0 // indirect golang.org/x/sys v0.29.0 // indirect golang.org/x/text v0.21.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-20240903143218-8af14fe29dc1 // indirect - google.golang.org/grpc v1.68.1 // indirect - google.golang.org/protobuf v1.35.2 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect + google.golang.org/grpc v1.69.4 // indirect + google.golang.org/protobuf v1.36.3 // indirect ) diff --git a/go.sum b/go.sum index fe5c480ed34..bca89e89903 100644 --- a/go.sum +++ b/go.sum @@ -29,6 +29,10 @@ github.com/go-git/go-billy/v5 v5.6.0 h1:w2hPNtoehvJIxR00Vb4xX94qHQi/ApZfX+nBE2Cj github.com/go-git/go-billy/v5 v5.6.0/go.mod h1:sFDq7xD3fn3E0GOwUSZqHo9lrkmx8xJhA0ZrfvjBRGM= github.com/go-git/go-git/v5 v5.13.0 h1:vLn5wlGIh/X78El6r3Jr+30W16Blk0CTcxTYcYPWi5E= github.com/go-git/go-git/v5 v5.13.0/go.mod h1:Wjo7/JyVKtQgUNdXYXIepzWfJQkUEIGvkvVkiXRR/zw= +github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= +github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68= github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA= github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= @@ -42,6 +46,8 @@ github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-checkpoint v0.5.0 h1:MFYpPZCnQqQTE18jFwSII6eUQrD/oxMFp3mlgcqk5mU= @@ -74,12 +80,12 @@ 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.24.0 h1:rUiyF+x1kYawXeRth6fKFm/MdfBS6+lW4NbeATsYz8Q= github.com/hashicorp/terraform-json v0.24.0/go.mod h1:Nfj5ubo9xbu9uiAoZVBsNOjvNKB66Oyrvtit74kC7ow= -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-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= +github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= 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= -github.com/hashicorp/terraform-registry-address v0.2.3/go.mod h1:lFHA76T8jfQteVfT7caREqguFrW3c4MFSPhZB7HHgUM= +github.com/hashicorp/terraform-registry-address v0.2.4 h1:JXu/zHB2Ymg/TGVCRu10XqNa4Sh2bWcqCNyKWjnCPJA= +github.com/hashicorp/terraform-registry-address v0.2.4/go.mod h1:tUNYTVyCtU4OIGXXMDp7WNcJ+0W1B4nmstVDgHMjfAU= github.com/hashicorp/terraform-svchost v0.1.1 h1:EZZimZ1GxdqFRinZ1tpJwVxxt49xc/S52uzrw4x0jKQ= github.com/hashicorp/terraform-svchost v0.1.1/go.mod h1:mNsjQfZyf/Jhz35v6/0LWcv26+X7JPS+buii2c9/ctc= github.com/hashicorp/yamux v0.1.1 h1:yrQxtgseBDrq9Y652vSRDvsKCJKOUD+GzTS4Y0Y8pvE= @@ -142,6 +148,16 @@ github.com/zclconf/go-cty v1.16.1 h1:a5TZEPzBFFR53udlIKApXzj8JIF4ZNQ6abH79z5R1S0 github.com/zclconf/go-cty v1.16.1/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= +go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= +go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= +go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= +go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= +go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= +go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= +go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= +go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= +go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= +go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc= @@ -153,8 +169,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.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I= -golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= +golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0= +golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k= 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= @@ -195,14 +211,14 @@ 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-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/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= +google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= +google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= +google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= 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.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= -google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= +google.golang.org/protobuf v1.36.3/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 33b7ee4a0720ec8a25a4c5e723c2453c0805f01d Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 22 Jan 2025 12:23:14 -0500 Subject: [PATCH 47/58] Replace fully qualified links with relative links in website documentation --- website/docs/plugin/sdkv2/resources/index.mdx | 2 +- website/docs/plugin/sdkv2/resources/write-only-attributes.mdx | 2 +- website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/website/docs/plugin/sdkv2/resources/index.mdx b/website/docs/plugin/sdkv2/resources/index.mdx index aa9f24bf121..898bce28ec9 100644 --- a/website/docs/plugin/sdkv2/resources/index.mdx +++ b/website/docs/plugin/sdkv2/resources/index.mdx @@ -37,5 +37,5 @@ When resource implementations change (due to bug fixes, improvements, or changes Write-only attributes are a special type of resource attribute whose values are not sent to Terraform and do not persist in the Terraform plan or state artifacts. -Write-only attributes can accept [ephemeral values](https://developer.hashicorp.com/terraform/language/resources/ephemeral). +Write-only attributes can accept [ephemeral values](/terraform/language/resources/ephemeral). The [Write-only attribute] page discusses how to create these attributes. \ No newline at end of file diff --git a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx index 7de185642e8..e793643540f 100644 --- a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx +++ b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx @@ -65,7 +65,7 @@ woVal, diags := d.GetRawConfigAt(cty.GetAttrPath("password_wo")) `(*schema.ResourceData).GetRawConfigAt()` uses `cty.Path` to specify locations in the raw configuration. -This is very similar to the `terraform-plugin-framework` [paths](https://developer.hashicorp.com/terraform/plugin/framework/handling-data/paths) or `terraform-plugin-testing` [json paths](https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/tfjson-paths). +This is very similar to the `terraform-plugin-framework` [paths](/terraform/plugin/framework/handling-data/paths) or `terraform-plugin-testing` [json paths](/terraform/plugin/testing/acceptance-tests/tfjson-paths). All top level attributes or blocks can be referred to using `cty.GetAttrPath()` diff --git a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx index 1945b3f5446..4be93f9a1d3 100644 --- a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx +++ b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx @@ -217,7 +217,7 @@ resource "example_instance" "ex" { such as passwords, API keys, etc. Write-only attribute values are not sent to Terraform and do not persist in the Terraform plan or state artifacts. Attributes marked -as `WriteOnly` can accept [ephemeral values](https://developer.hashicorp.com/terraform/language/resources/ephemeral). +as `WriteOnly` can accept [ephemeral values](/terraform/language/resources/ephemeral). **Schema example:** From d1d4c14d859e91a7dbbd8bb12aad9cfadf0377c0 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 22 Jan 2025 15:18:12 -0500 Subject: [PATCH 48/58] Add more test cases for `GetRawConfigAt()` --- helper/schema/resource_data_test.go | 64 +++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index 5210218116e..12b1d010410 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -3971,6 +3971,36 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { Path: cty.GetAttrPath("ConfigAttribute"), Value: cty.NumberIntVal(42), }, + "root level set attribute": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.SetVal([]cty.Value{ + cty.StringVal("valueA"), + cty.StringVal("valueB"), + }), + }), + Path: cty.GetAttrPath("ConfigAttribute").Index(cty.StringVal("valueA")), + Value: cty.StringVal("valueA"), + }, + "root level list attribute": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.ListVal([]cty.Value{ + cty.StringVal("valueA"), + cty.StringVal("valueB"), + }), + }), + Path: cty.GetAttrPath("ConfigAttribute").IndexInt(0), + Value: cty.StringVal("valueA"), + }, + "root level map attribute": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.MapVal(map[string]cty.Value{ + "mapA": cty.StringVal("valueA"), + "mapB": cty.StringVal("valueB"), + }), + }), + Path: cty.GetAttrPath("ConfigAttribute").IndexString("mapB"), + Value: cty.StringVal("valueB"), + }, "list nested block attribute - get attribute value": { RawConfig: cty.ObjectVal(map[string]cty.Value{ "list_nested_block": cty.ListVal([]cty.Value{ @@ -3985,6 +4015,36 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { Path: cty.GetAttrPath("list_nested_block").IndexInt(1).GetAttr("ConfigAttribute"), Value: cty.StringVal("valueB"), }, + "set nested block attribute - get attribute value": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "set_nested_block": cty.SetVal([]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("set_nested_block").Index(cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.StringVal("valueB"), + })).GetAttr("ConfigAttribute"), + Value: cty.StringVal("valueB"), + }, + "map nested block attribute - get attribute value": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "map_nested_block": cty.MapVal(map[string]cty.Value{ + "mapA": cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.StringVal("valueA"), + }), + "mapB": cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.StringVal("valueB"), + }), + }), + }), + Path: cty.GetAttrPath("map_nested_block").IndexString("mapB").GetAttr("ConfigAttribute"), + Value: cty.StringVal("valueB"), + }, } for tn, tc := range cases { @@ -3997,10 +4057,6 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { } 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) } From 1b935c4bbe55643b125acb3e8200a738016e10c1 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Wed, 22 Jan 2025 15:18:40 -0500 Subject: [PATCH 49/58] Add link to ephemeral resource documentation --- website/docs/plugin/sdkv2/resources/write-only-attributes.mdx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx index e793643540f..77d02a85b8b 100644 --- a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx +++ b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx @@ -10,7 +10,7 @@ description: Implementing write-only attributes within resources. Write-only attributes are managed resource attributes whose values are not saved to the Terraform plan or state artifacts. Write-only attributes should be used to handle secret values that do not need to be persisted in Terraform state, such as passwords, API keys, etc. The provider is expected to be the terminal point for an ephemeral value, -which should either use the value by making the appropriate change to the API or ignore the value. Write-only attributes can accept ephemeral values. +which should either use the value by making the appropriate change to the API or ignore the value. Write-only attributes can accept [ephemeral values](/terraform/language/resources/ephemeral). ## General Concepts @@ -55,7 +55,7 @@ Write-only attribute values are only available in the raw resource configuration Use the `(*schema.ResourceData).GetRawConfigAt()` method to retrieve the raw config value. -This method is an advanced method that uses the `Hashicorp/cty` library for its type system. +This method is an advanced method that uses the `hashicorp/go-cty` library for its type system. ```go woVal, diags := d.GetRawConfigAt(cty.GetAttrPath("password_wo")) From 5070aebb0859e2dc8caba7c836a44c1862d5298a Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 24 Jan 2025 14:42:05 -0500 Subject: [PATCH 50/58] Apply suggestions from code review Co-authored-by: Austin Valle --- .../unreleased/FEATURES-20250121-165644.yaml | 2 +- helper/schema/schema.go | 2 +- helper/schema/write_only.go | 4 ++-- internal/configs/configschema/schema.go | 2 +- website/docs/plugin/sdkv2/resources/index.mdx | 6 ++--- .../sdkv2/resources/write-only-attributes.mdx | 24 +++++++++---------- 6 files changed, 20 insertions(+), 20 deletions(-) diff --git a/.changes/unreleased/FEATURES-20250121-165644.yaml b/.changes/unreleased/FEATURES-20250121-165644.yaml index 95b0f03e04c..83ff178c9d9 100644 --- a/.changes/unreleased/FEATURES-20250121-165644.yaml +++ b/.changes/unreleased/FEATURES-20250121-165644.yaml @@ -1,6 +1,6 @@ kind: FEATURES body: 'helper/schema: Added `WriteOnly` schema behavior for managed resource schemas to indicate a write-only attribute. -Write-only attributes are managed resource attributes whose values are not saved to the Terraform plan or state artifacts.' +Write-only attribute values are not saved to the Terraform plan or state artifacts.' time: 2025-01-21T16:56:44.038893-05:00 custom: Issue: "1375" diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 215eb8b9b95..6719260fb65 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -397,7 +397,7 @@ type Schema struct { Sensitive bool // WriteOnly indicates that the practitioner can choose a value for this - // attribute, but Terraform will not store this attribute in state. + // attribute, but Terraform will not store this attribute in plan or state. // WriteOnly can only be set for managed resource schemas. If WriteOnly is true, // either Optional or Required must also be true. WriteOnly cannot be set with ForceNew. // diff --git a/helper/schema/write_only.go b/helper/schema/write_only.go index 48b8d9f60f7..287c8bd8fdf 100644 --- a/helper/schema/write_only.go +++ b/helper/schema/write_only.go @@ -142,8 +142,8 @@ func validateWriteOnlyNullValues(val cty.Value, schema *configschema.Block, path if attr.WriteOnly && !v.IsNull() { diags = append(diags, diag.Diagnostic{ Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: fmt.Sprintf("The resource contains a non-null value for WriteOnly attribute %q ", name) + + Summary: "Write-only Attribute Not Allowed", + Detail: fmt.Sprintf("The resource contains a non-null value for write-only attribute %q ", name) + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: append(path, cty.GetAttrStep{Name: name}), }) diff --git a/internal/configs/configschema/schema.go b/internal/configs/configschema/schema.go index 97d91b699eb..983d20bdf08 100644 --- a/internal/configs/configschema/schema.go +++ b/internal/configs/configschema/schema.go @@ -85,7 +85,7 @@ type Attribute struct { Deprecated bool // WriteOnly indicates that the practitioner can choose a value for this - // attribute, but Terraform will not store this attribute in state. + // attribute, but Terraform will not store this attribute in plan or state. // WriteOnly can only be set for managed resource schemas. If WriteOnly is true, // either Optional or Required must also be true. WriteOnly cannot be set with ForceNew. // diff --git a/website/docs/plugin/sdkv2/resources/index.mdx b/website/docs/plugin/sdkv2/resources/index.mdx index 898bce28ec9..bfdb4406fe4 100644 --- a/website/docs/plugin/sdkv2/resources/index.mdx +++ b/website/docs/plugin/sdkv2/resources/index.mdx @@ -35,7 +35,7 @@ When resource implementations change (due to bug fixes, improvements, or changes ~> **NOTE:** Write-only attributes are only supported in Terraform `v1.11` or higher -Write-only attributes are a special type of resource attribute -whose values are not sent to Terraform and do not persist in the Terraform plan or state artifacts. -Write-only attributes can accept [ephemeral values](/terraform/language/resources/ephemeral). +Write-only attributes are a special type of managed resource attribute +that are configured by practitioners but are not persisted in the Terraform plan or state artifacts. +Write-only attributes can accept [ephemeral values](/terraform/language/resources/ephemeral) and are not required to be consistent between plan and apply operations.. The [Write-only attribute] page discusses how to create these attributes. \ No newline at end of file diff --git a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx index 77d02a85b8b..2c9ca158d2b 100644 --- a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx +++ b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx @@ -7,10 +7,10 @@ description: Implementing write-only attributes within resources. ~> **NOTE:** Write-only attributes are only supported in Terraform `v1.11` or higher -Write-only attributes are managed resource attributes whose values are not saved to the Terraform plan or state artifacts. Write-only attributes +Write-only attributes are managed resource attributes that are configured by practitioners but are not persisted to the Terraform plan or state artifacts. Write-only attributes should be used to handle secret values that do not need to be persisted in Terraform state, such as passwords, API keys, etc. The provider is expected to be the terminal point for an ephemeral value, -which should either use the value by making the appropriate change to the API or ignore the value. Write-only attributes can accept [ephemeral values](/terraform/language/resources/ephemeral). +which should either use the value by making the appropriate change to the API or ignore the value. Write-only attributes can accept [ephemeral values](/terraform/language/resources/ephemeral) and are not required to be consistent between plan and apply operations. ## General Concepts @@ -18,13 +18,13 @@ The following are high level differences between regular attributes and write-on - Write-only attributes can accept ephemeral and non-ephemeral values -- Write-only attribute values are only available in the configuration, and the prior state, planned state, and final state values for +- Write-only attribute values are only available in the configuration. The prior state, planned state, and final state values for write-only attributes should always be `null`. - - Provider developers do not need to “clean up” write-only attribute values after using them as the SDKv2 will handle the nullification of write-only attributes for all RPCs. + - Provider developers do not need to explicitly set write-only attribute values to `null` after using them as the SDKv2 will handle the nullification of write-only attributes for all RPCs. -- Any value that is set for a write-only attribute using `(*ResourceData).Set()` by the provider will be set to `null` by SDKv2 before the RPC response is sent to Terraform +- Any value that is set for a write-only attribute using `(*ResourceData).Set()` by the provider will be reverted to `null` by SDKv2 before the RPC response is sent to Terraform -- Write-only attribute values on its own will never cause a Terraform diff. +- Write-only attribute values cannot produce a Terraform plan difference. - This is because the prior state value for a write-only attribute will always be `null` and the planned/final state value will also be `null`, therefore, it cannot produce a diff on its own. - The one exception to this case is if the write-only attribute is added to `requires_replace` via [CustomizeDiff](/terraform/plugin/sdkv2/resources/customizing-differences), in that case, the write-only attribute will always cause a diff/trigger a resource recreation @@ -112,7 +112,7 @@ Index(cty.ObjectVal(map[string]cty.Value{"set_nested_block_attribute": cty.Strin ### cty.Value -When working with `cty.Value`, you must always check the type of the value before converting it to a Go value or else the conversion will cause a panic. +When working with `cty.Value`, you must always check the type of the value before converting it to a Go value or else the conversion could cause a panic. ```go // Check that the type is a cty.String before conversion @@ -127,9 +127,9 @@ if !woVal.IsNull() { } ``` -## PreferWriteOnly Validator +## PreferWriteOnlyAttribute Validator -`PreferWriteOnlyAttribute()` is a validator that takes a `cty.Path` to a regular attribute and a `cty.Path` to a write-only attribute. +`PreferWriteOnlyAttribute()` is a validator that takes a `cty.Path` to an existing configuration attribute (required/optional) and a `cty.Path` to a write-only attribute. Use this validator when you have a write-only version of an existing attribute, and you want to encourage practitioners to use the write-only version whenever possible. @@ -170,8 +170,8 @@ When using `cty.Path` to traverse into a nested block, use an unknown value to i ## Best Practices -Since write-only attributes have no prior values, we cannot determine user intent with a write-only attribute alone. To determine when to use/not use a write-only attribute value in your provider, we recommend using other attributes in the provider. For example: +Since write-only attributes have no prior values, user intent cannot be determined with a write-only attribute alone. To determine when to use/not use a write-only attribute value in your provider, we recommend using other non-write-only attributes in the provider. For example: -- Pair write-only attributes with a regular attribute to “trigger” the use of the write-only attribute - - For example, a `password_wo` write-only attribute can be paired with a configured `password_wo_version` attribute. When the `password_wo_version` updates, the provider will send the `password_wo` value to the API. +- Pair write-only attributes with a configuration attribute (required or optional) to “trigger” the use of the write-only attribute + - For example, a `password_wo` write-only attribute can be paired with a configured `password_wo_version` attribute. When the `password_wo_version` is modified, the provider will send the `password_wo` value to the API. - Use a keepers attribute (which is used in the [Random Provider](https://registry.terraform.io/providers/hashicorp/random/latest/docs#resource-keepers)) that will take in arbitrary key-pair values. Whenever there is a change to the `keepers` attribute, the provider will use the write-only attribute value. \ No newline at end of file From 4b5b3639e4b0ead4856a1fccc78ad0d113775403 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 24 Jan 2025 14:55:46 -0500 Subject: [PATCH 51/58] Apply suggestions from code review Co-authored-by: Austin Valle --- helper/schema/provider.go | 6 +++--- helper/schema/resource.go | 2 +- helper/validation/write_only.go | 6 +++--- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/helper/schema/provider.go b/helper/schema/provider.go index d60e2d3764e..45f1e0d466b 100644 --- a/helper/schema/provider.go +++ b/helper/schema/provider.go @@ -201,13 +201,13 @@ func (p *Provider) InternalValidate() error { } if sm.hasWriteOnly() { - validationErrors = append(validationErrors, fmt.Errorf("provider schema cannot contain WriteOnly attributes")) + validationErrors = append(validationErrors, fmt.Errorf("provider schema cannot contain write-only attributes")) } // Provider meta schema validation providerMeta := schemaMap(p.ProviderMetaSchema) if providerMeta.hasWriteOnly() { - validationErrors = append(validationErrors, fmt.Errorf("provider meta schema cannot contain WriteOnly attributes")) + validationErrors = append(validationErrors, fmt.Errorf("provider meta schema cannot contain write-only attributes")) } // Provider-specific checks @@ -234,7 +234,7 @@ func (p *Provider) InternalValidate() error { dataSourceSchema := schemaMap(r.SchemaMap()) if dataSourceSchema.hasWriteOnly() { - validationErrors = append(validationErrors, fmt.Errorf("data source %s cannot contain WriteOnly attributes", k)) + validationErrors = append(validationErrors, fmt.Errorf("data source %s cannot contain write-only attributes", k)) } } diff --git a/helper/schema/resource.go b/helper/schema/resource.go index e9b82d37ccc..32a21d2edfa 100644 --- a/helper/schema/resource.go +++ b/helper/schema/resource.go @@ -652,7 +652,7 @@ type Resource struct { // diagnostics based on the inspection of those values. // // ValidateRawResourceConfigFuncs is only valid for Managed Resource types and will not be - // called for Data Resource or Block types. + // called for Data Resource or Provider types. // // Developers should prefer other validation methods first as this validation function // deals with raw cty values. diff --git a/helper/validation/write_only.go b/helper/validation/write_only.go index 78b03ec23cf..303e72dd2d1 100644 --- a/helper/validation/write_only.go +++ b/helper/validation/write_only.go @@ -100,9 +100,9 @@ func PreferWriteOnlyAttribute(oldAttribute cty.Path, writeOnlyAttribute cty.Path 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.", attrStep.Name, writeOnlyAttrStep.Name), + Summary: "Available Write-only Attribute Alternative", + Detail: fmt.Sprintf("The attribute %s has a write-only alternative %s available. "+ + "Use the write-only alternative of the attribute when possible.", attrStep.Name, writeOnlyAttrStep.Name), AttributePath: attr.path, }) } From c1a155684b752efeed283542a664ef7ab5e93742 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 24 Jan 2025 15:13:58 -0500 Subject: [PATCH 52/58] Fix write-only attribute error assertions --- helper/schema/grpc_provider_test.go | 20 ++--- helper/schema/provider_test.go | 6 +- helper/schema/write_only_test.go | 60 +++++++------- helper/validation/write_only_test.go | 120 +++++++++++++-------------- 4 files changed, 103 insertions(+), 103 deletions(-) diff --git a/helper/schema/grpc_provider_test.go b/helper/schema/grpc_provider_test.go index ff90044d433..a343daf809f 100644 --- a/helper/schema/grpc_provider_test.go +++ b/helper/schema/grpc_provider_test.go @@ -3581,8 +3581,8 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"foo\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only attribute \"foo\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, @@ -3629,15 +3629,15 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"bar\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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 resource contains a non-null value for WriteOnly attribute \"foo\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only attribute \"foo\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", Attribute: tftypes.NewAttributePath().WithAttributeName("foo"), }, @@ -3711,15 +3711,15 @@ func TestGRPCProviderServerValidateResourceTypeConfig(t *testing.T) { Diagnostics: []*tfprotov5.Diagnostic{ { Severity: tfprotov5.DiagnosticSeverityError, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"foo\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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 resource contains a non-null value for WriteOnly attribute \"writeonly_nested_attr\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only attribute \"writeonly_nested_attr\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", Attribute: tftypes.NewAttributePath(). WithAttributeName("config_block_attr"). diff --git a/helper/schema/provider_test.go b/helper/schema/provider_test.go index e43c8878fe8..8da40f5c656 100644 --- a/helper/schema/provider_test.go +++ b/helper/schema/provider_test.go @@ -2356,7 +2356,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, }, }, - ExpectedErr: fmt.Errorf("provider schema cannot contain WriteOnly attributes"), + ExpectedErr: fmt.Errorf("provider schema cannot contain write-only attributes"), }, "Provider meta schema with WriteOnly attribute set returns an error": { P: &Provider{ @@ -2374,7 +2374,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, }, }, - ExpectedErr: fmt.Errorf("provider meta schema cannot contain WriteOnly attributes"), + ExpectedErr: fmt.Errorf("provider meta schema cannot contain write-only attributes"), }, "Data source schema with WriteOnly attribute set returns an error": { P: &Provider{ @@ -2396,7 +2396,7 @@ func TestProvider_InternalValidate(t *testing.T) { }, }, }, - ExpectedErr: fmt.Errorf("data source data-foo cannot contain WriteOnly attributes"), + ExpectedErr: fmt.Errorf("data source data-foo cannot contain write-only attributes"), }, "Resource schema with WriteOnly attribute set returns no errors": { P: &Provider{ diff --git a/helper/schema/write_only_test.go b/helper/schema/write_only_test.go index f594981eb97..1f956d48693 100644 --- a/helper/schema/write_only_test.go +++ b/helper/schema/write_only_test.go @@ -441,8 +441,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_attribute\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -450,8 +450,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -503,8 +503,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_attribute\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -512,8 +512,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -560,8 +560,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { 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\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -602,8 +602,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { 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\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -649,8 +649,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { diag.Diagnostics{ { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -663,8 +663,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute1\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -712,8 +712,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { 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\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -723,8 +723,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -771,8 +771,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { 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\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -782,8 +782,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -845,8 +845,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { 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\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, @@ -855,8 +855,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { }, { Severity: diag.Error, - Summary: "WriteOnly Attribute Not Allowed", - Detail: "The resource contains a non-null value for WriteOnly attribute \"write_only_block_attribute\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only attribute \"write_only_block_attribute\" " + "Write-only attributes are only supported in Terraform 1.11 and later.", AttributePath: cty.Path{ @@ -899,8 +899,8 @@ func Test_validateWriteOnlyNullValues(t *testing.T) { 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\" " + + Summary: "Write-only Attribute Not Allowed", + Detail: "The resource contains a non-null value for write-only 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"}, diff --git a/helper/validation/write_only_test.go b/helper/validation/write_only_test.go index b4dd2445651..97388826df2 100644 --- a/helper/validation/write_only_test.go +++ b/helper/validation/write_only_test.go @@ -71,9 +71,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{cty.GetAttrStep{Name: "oldAttribute"}}, }, }, @@ -121,9 +121,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{cty.GetAttrStep{Name: "oldAttribute"}}, }, }, @@ -146,9 +146,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "config_block_attr"}, cty.GetAttrStep{Name: "oldAttribute"}, @@ -190,9 +190,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "config_block_attr"}, cty.GetAttrStep{Name: "oldAttribute"}, @@ -221,9 +221,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "list_nested_block"}, cty.IndexStep{Key: cty.NumberIntVal(0)}, @@ -271,9 +271,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "list_nested_block"}, cty.IndexStep{Key: cty.NumberIntVal(0)}, @@ -311,9 +311,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "list_nested_block"}, cty.IndexStep{Key: cty.NumberIntVal(0)}, @@ -322,9 +322,9 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, { 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "list_nested_block"}, cty.IndexStep{Key: cty.NumberIntVal(2)}, @@ -359,9 +359,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "set_nested_block"}, cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ @@ -420,9 +420,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "set_nested_block"}, cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ @@ -462,9 +462,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "set_nested_block"}, cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ @@ -476,9 +476,9 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, { 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "set_nested_block"}, cty.IndexStep{Key: cty.ObjectVal(map[string]cty.Value{ @@ -511,9 +511,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "map_nested_block"}, cty.IndexStep{Key: cty.StringVal("key1")}, @@ -561,9 +561,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "map_nested_block"}, cty.IndexStep{Key: cty.StringVal("key1")}, @@ -601,9 +601,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "map_nested_block"}, cty.IndexStep{Key: cty.StringVal("key1")}, @@ -612,9 +612,9 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, { 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "map_nested_block"}, cty.IndexStep{Key: cty.StringVal("key3")}, @@ -685,9 +685,9 @@ 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "map_nested_block"}, cty.IndexStep{Key: cty.StringVal("key1")}, @@ -702,9 +702,9 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, { 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "map_nested_block"}, cty.IndexStep{Key: cty.StringVal("key1")}, @@ -718,9 +718,9 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, { 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "map_nested_block"}, cty.IndexStep{Key: cty.StringVal("key3")}, @@ -734,9 +734,9 @@ func TestPreferWriteOnlyAttribute(t *testing.T) { }, { 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.", + Summary: "Available Write-only Attribute Alternative", + Detail: "The attribute oldAttribute has a write-only alternative writeOnlyAttribute available. " + + "Use the write-only alternative of the attribute when possible.", AttributePath: cty.Path{ cty.GetAttrStep{Name: "map_nested_block"}, cty.IndexStep{Key: cty.StringVal("key3")}, From d40b6b0819660ea77678bbf42831eea0ec5dc790 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 24 Jan 2025 16:12:17 -0500 Subject: [PATCH 53/58] Prevent `WriteOnly` from being used with `Default` and `DefaultFunc`. --- helper/schema/schema.go | 8 ++ helper/schema/schema_test.go | 77 ++++++++++--------- .../sdkv2/resources/write-only-attributes.mdx | 5 +- .../plugin/sdkv2/schemas/schema-behaviors.mdx | 4 +- 4 files changed, 55 insertions(+), 39 deletions(-) diff --git a/helper/schema/schema.go b/helper/schema/schema.go index 6719260fb65..0c779b31dec 100644 --- a/helper/schema/schema.go +++ b/helper/schema/schema.go @@ -898,6 +898,14 @@ func (m schemaMap) internalValidate(topSchemaMap schemaMap, attrsOnly bool) erro return fmt.Errorf("%s: Default cannot be set with Required", k) } + if v.WriteOnly && v.Default != nil { + return fmt.Errorf("%s: Default cannot be set with WriteOnly", k) + } + + if v.WriteOnly && v.DefaultFunc != nil { + return fmt.Errorf("%s: DefaultFunc cannot be set with WriteOnly", k) + } + if len(v.ComputedWhen) > 0 && !v.Computed { return fmt.Errorf("%s: ComputedWhen can only be set with Computed", k) } diff --git a/helper/schema/schema_test.go b/helper/schema/schema_test.go index a8dcc094947..d0fa1a9fdf9 100644 --- a/helper/schema/schema_test.go +++ b/helper/schema/schema_test.go @@ -5135,6 +5135,46 @@ func TestSchemaMap_InternalValidate(t *testing.T) { true, }, + "Attribute with WriteOnly, Optional, and Default set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + Default: true, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with WriteOnly, Optional, and DefaultFunc set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Optional: true, + DefaultFunc: func() (interface{}, error) { + return "foo", nil + }, + WriteOnly: true, + }, + }, + true, + }, + + "Attribute with WriteOnly, Required, and DefaultFunc set returns error": { + map[string]*Schema{ + "foo": { + Type: TypeString, + Required: true, + DefaultFunc: func() (interface{}, error) { + return "foo", nil + }, + WriteOnly: true, + }, + }, + true, + }, + "Attribute with only WriteOnly set returns error": { map[string]*Schema{ "foo": { @@ -7088,43 +7128,6 @@ func TestSchemaMap_Validate(t *testing.T) { }, }, }, - "Required + WriteOnly attribute with null value returns validation error": { - Schema: map[string]*Schema{ - "write_only_attribute": { - Type: TypeString, - Required: true, - WriteOnly: true, - }, - }, - - Config: nil, - Err: true, - }, - "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 validation error": { - Schema: map[string]*Schema{ - "write_only_attribute": { - Type: TypeString, - Required: true, - WriteOnly: true, - DefaultFunc: func() (interface{}, error) { return nil, nil }, - }, - }, - - Config: nil, - Err: true, - }, } for tn, tc := range cases { diff --git a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx index 2c9ca158d2b..6f343c7b0c0 100644 --- a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx +++ b/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx @@ -44,9 +44,12 @@ write-only attributes should always be `null`. **Restrictions:** -- Cannot be used in data source or provider schemas. +- Cannot be used in data source or provider schemas - Must be set with either `Required` is `true` or `Optional` is `true` - Cannot be used when `Computed` is `true` +- Cannot be used when `ForceNew` is `true` +- Cannot be used when `Default` is `specified` +- Cannot be used with `DefaultFunc` - Cannot be used with aggregate schema types (e.g. `typeMap`, `typeList`, `typeSet`) ## Retrieving Write-only Values diff --git a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx index 4be93f9a1d3..dc530914da6 100644 --- a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx +++ b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx @@ -207,10 +207,12 @@ resource "example_instance" "ex" { **Restrictions:** -- Cannot be used in data source or provider schemas. +- Cannot be used in data source or provider schemas - Must be set with either `Required` is `true` or `Optional` is `true` - Cannot be used when `Computed` is `true` - Cannot be used when `ForceNew` is `true` +- Cannot be used when `Default` is `specified` +- Cannot be used with `DefaultFunc` - Cannot be used with aggregate schema types (e.g. `typeMap`, `typeList`, `typeSet`) `WriteOnly` should be used for attributes that handle secret values that do not need to be persisted in Terraform plan or state, From 6d9ab94d95822aa307db763647d71061faf8389b Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 24 Jan 2025 16:29:45 -0500 Subject: [PATCH 54/58] Update error messaging --- helper/schema/resource_data.go | 6 +++--- helper/schema/resource_data_test.go | 6 +++--- helper/schema/resource_diff.go | 7 ++++--- helper/schema/resource_diff_test.go | 7 ++++--- 4 files changed, 14 insertions(+), 12 deletions(-) diff --git a/helper/schema/resource_data.go b/helper/schema/resource_data.go index 5d15abe4884..5129c925c4c 100644 --- a/helper/schema/resource_data.go +++ b/helper/schema/resource_data.go @@ -621,9 +621,9 @@ func (d *ResourceData) GetRawConfigAt(valPath cty.Path) (cty.Value, diag.Diagnos 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. " + + Summary: "Empty Raw Config", + Detail: "The Terraform Provider unexpectedly received an empty configuration. " + + "This is almost always an issue with the Terraform Plugin SDK used to create providers. " + "Please report this to the provider developers. \n\n" + "The RawConfig is empty.", AttributePath: valPath, diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index 12b1d010410..7135d03948f 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -3933,9 +3933,9 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { 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. " + + Summary: "Empty Raw Config", + Detail: "The Terraform Provider unexpectedly received an empty configuration. " + + "This is almost always an issue with the Terraform Plugin SDK used to create providers. " + "Please report this to the provider developers. \n\n" + "The RawConfig is empty.", AttributePath: cty.Path{ diff --git a/helper/schema/resource_diff.go b/helper/schema/resource_diff.go index 404bad49f33..9f7dab683b4 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" ) @@ -495,9 +496,9 @@ func (d *ResourceDiff) GetRawConfigAt(valPath cty.Path) (cty.Value, diag.Diagnos 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. " + + Summary: "Empty Raw Config", + Detail: "The Terraform Provider unexpectedly received an empty configuration. " + + "This is almost always an issue with the Terraform Plugin SDK used to create providers. " + "Please report this to the provider developers. \n\n" + "The RawConfig is empty.", AttributePath: valPath, diff --git a/helper/schema/resource_diff_test.go b/helper/schema/resource_diff_test.go index d0ba189c0b3..ef5198214bf 100644 --- a/helper/schema/resource_diff_test.go +++ b/helper/schema/resource_diff_test.go @@ -13,6 +13,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/terraform" @@ -2323,9 +2324,9 @@ func TestResourceDiffGetRawConfigAt(t *testing.T) { 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. " + + Summary: "Empty Raw Config", + Detail: "The Terraform Provider unexpectedly received an empty configuration. " + + "This is almost always an issue with the Terraform Plugin SDK used to create providers. " + "Please report this to the provider developers. \n\n" + "The RawConfig is empty.", AttributePath: cty.Path{ From 91bc003cc04215b5675c0b604857083215ff6842 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 24 Jan 2025 16:42:29 -0500 Subject: [PATCH 55/58] Add null value test case --- helper/schema/resource_data_test.go | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/helper/schema/resource_data_test.go b/helper/schema/resource_data_test.go index 7135d03948f..5e1d53e8a01 100644 --- a/helper/schema/resource_data_test.go +++ b/helper/schema/resource_data_test.go @@ -3944,6 +3944,13 @@ func TestResourceDataGetRawConfigAt(t *testing.T) { }, }, }, + "null value in config": { + RawConfig: cty.ObjectVal(map[string]cty.Value{ + "ConfigAttribute": cty.NullVal(cty.Number), + }), + Path: cty.GetAttrPath("ConfigAttribute"), + Value: cty.NullVal(cty.Number), + }, "invalid path returns error": { RawConfig: cty.ObjectVal(map[string]cty.Value{ "ConfigAttribute": cty.NumberIntVal(42), From 2c0793b60d00fec404d327cadcaecddf877cd222 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Fri, 24 Jan 2025 16:54:47 -0500 Subject: [PATCH 56/58] Rename "write-only attributes" to "write-only arguments" in website documentation --- website/data/plugin-sdkv2-nav-data.json | 4 +- website/docs/plugin/sdkv2/resources/index.mdx | 10 ++-- ...ttributes.mdx => write-only-arguments.mdx} | 48 +++++++++---------- .../plugin/sdkv2/schemas/schema-behaviors.mdx | 10 ++-- 4 files changed, 36 insertions(+), 36 deletions(-) rename website/docs/plugin/sdkv2/resources/{write-only-attributes.mdx => write-only-arguments.mdx} (60%) diff --git a/website/data/plugin-sdkv2-nav-data.json b/website/data/plugin-sdkv2-nav-data.json index 49fd9a2ae55..74574dc34e6 100644 --- a/website/data/plugin-sdkv2-nav-data.json +++ b/website/data/plugin-sdkv2-nav-data.json @@ -40,8 +40,8 @@ "path": "resources/state-migration" }, { - "title": "Write-only Attributes", - "path": "resources/write-only-attributes" + "title": "Write-only Arguments", + "path": "resources/write-only-arguments" } ] }, diff --git a/website/docs/plugin/sdkv2/resources/index.mdx b/website/docs/plugin/sdkv2/resources/index.mdx index bfdb4406fe4..127148e9683 100644 --- a/website/docs/plugin/sdkv2/resources/index.mdx +++ b/website/docs/plugin/sdkv2/resources/index.mdx @@ -31,11 +31,11 @@ Resources define the data types and API interactions required to create, update, When resource implementations change (due to bug fixes, improvements, or changes to the backend APIs Terraform interacts with), they can sometimes become incompatible with existing state. When this happens, a migration is needed for resources provisioned in the wild with old schema configurations. Terraform resources support migrating state values in these scenarios via [State Migration](/terraform/plugin/sdkv2/resources/state-migration). -## Write-only Attributes +## Write-only Arguments -~> **NOTE:** Write-only attributes are only supported in Terraform `v1.11` or higher +~> **NOTE:** Write-only arguments are only supported in Terraform `v1.11` or higher -Write-only attributes are a special type of managed resource attribute +Write-only arguments are a special type of managed resource attribute that are configured by practitioners but are not persisted in the Terraform plan or state artifacts. -Write-only attributes can accept [ephemeral values](/terraform/language/resources/ephemeral) and are not required to be consistent between plan and apply operations.. -The [Write-only attribute] page discusses how to create these attributes. \ No newline at end of file +Write-only arguments can accept [ephemeral values](/terraform/language/resources/ephemeral) and are not required to be consistent between plan and apply operations. +The [Write-only arguments](/terraform/plugin/sdkv2/resources/write-only-arguments) page discusses how to create these arguments. \ No newline at end of file diff --git a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx b/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx similarity index 60% rename from website/docs/plugin/sdkv2/resources/write-only-attributes.mdx rename to website/docs/plugin/sdkv2/resources/write-only-arguments.mdx index 6f343c7b0c0..986655c447c 100644 --- a/website/docs/plugin/sdkv2/resources/write-only-attributes.mdx +++ b/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx @@ -1,34 +1,34 @@ --- -page_title: Resources - Write-only Attributes -description: Implementing write-only attributes within resources. +page_title: Resources - Write-only Arguments +description: Implementing write-only arguments within resources. --- -# Resources - Write-only Attributes +# Resources - Write-only Arguments -~> **NOTE:** Write-only attributes are only supported in Terraform `v1.11` or higher +~> **NOTE:** Write-only arguments are only supported in Terraform `v1.11` or higher -Write-only attributes are managed resource attributes that are configured by practitioners but are not persisted to the Terraform plan or state artifacts. Write-only attributes +Write-only arguments are managed resource attributes that are configured by practitioners but are not persisted to the Terraform plan or state artifacts. Write-only arguments should be used to handle secret values that do not need to be persisted in Terraform state, such as passwords, API keys, etc. The provider is expected to be the terminal point for an ephemeral value, -which should either use the value by making the appropriate change to the API or ignore the value. Write-only attributes can accept [ephemeral values](/terraform/language/resources/ephemeral) and are not required to be consistent between plan and apply operations. +which should either use the value by making the appropriate change to the API or ignore the value. Write-only arguments can accept [ephemeral values](/terraform/language/resources/ephemeral) and are not required to be consistent between plan and apply operations. ## General Concepts -The following are high level differences between regular attributes and write-only attributes: +The following are high level differences between `Required`/`Optional` arguments and write-only arguments: -- Write-only attributes can accept ephemeral and non-ephemeral values +- Write-only arguments can accept ephemeral and non-ephemeral values -- Write-only attribute values are only available in the configuration. The prior state, planned state, and final state values for -write-only attributes should always be `null`. - - Provider developers do not need to explicitly set write-only attribute values to `null` after using them as the SDKv2 will handle the nullification of write-only attributes for all RPCs. +- Write-only argument values are only available in the configuration. The prior state, planned state, and final state values for +write-only arguments should always be `null`. + - Provider developers do not need to explicitly set write-only argument values to `null` after using them as the SDKv2 will handle the nullification of write-only arguments for all RPCs. -- Any value that is set for a write-only attribute using `(*ResourceData).Set()` by the provider will be reverted to `null` by SDKv2 before the RPC response is sent to Terraform +- Any value that is set for a write-only argument using `(*ResourceData).Set()` by the provider will be reverted to `null` by SDKv2 before the RPC response is sent to Terraform -- Write-only attribute values cannot produce a Terraform plan difference. - - This is because the prior state value for a write-only attribute will always be `null` and the planned/final state value will also be `null`, therefore, it cannot produce a diff on its own. - - The one exception to this case is if the write-only attribute is added to `requires_replace` via [CustomizeDiff](/terraform/plugin/sdkv2/resources/customizing-differences), in that case, the write-only attribute will always cause a diff/trigger a resource recreation +- Write-only argument values cannot produce a Terraform plan difference. + - This is because the prior state value for a write-only argument will always be `null` and the planned/final state value will also be `null`, therefore, it cannot produce a diff on its own. + - The one exception to this case is if the write-only argument is added to `requires_replace` via [CustomizeDiff](/terraform/plugin/sdkv2/resources/customizing-differences), in that case, the write-only argument will always cause a diff/trigger a resource recreation -- Since write-only attributes can accept ephemeral values, write-only attribute configuration values are not expected to be consistent between plan and apply. +- Since write-only arguments can accept ephemeral values, write-only argument configuration values are not expected to be consistent between plan and apply. ## Schema Behavior @@ -50,11 +50,11 @@ write-only attributes should always be `null`. - Cannot be used when `ForceNew` is `true` - Cannot be used when `Default` is `specified` - Cannot be used with `DefaultFunc` -- Cannot be used with aggregate schema types (e.g. `typeMap`, `typeList`, `typeSet`) +- Cannot be used with aggregate schema types (e.g. `typeMap`, `typeList`, `typeSet`), but non-computed nested block types can contain write-only arguments. ## Retrieving Write-only Values -Write-only attribute values are only available in the raw resource configuration, you cannot retrieve it using `(*resourceData).Get()` like other attribute values. +Write-only argument values are only available in the raw resource configuration, you cannot retrieve it using `(*resourceData).Get()` like other attribute values. Use the `(*schema.ResourceData).GetRawConfigAt()` method to retrieve the raw config value. @@ -120,7 +120,7 @@ When working with `cty.Value`, you must always check the type of the value befor ```go // Check that the type is a cty.String before conversion if !woVal.Type().Equals(cty.String) { - return errors.New("error retrieving write-only attribute: password_wo - retrieved config value is not a string") + return errors.New("error retrieving write-only argument: password_wo - retrieved config value is not a string") } // Check if the value is not null @@ -132,7 +132,7 @@ if !woVal.IsNull() { ## PreferWriteOnlyAttribute Validator -`PreferWriteOnlyAttribute()` is a validator that takes a `cty.Path` to an existing configuration attribute (required/optional) and a `cty.Path` to a write-only attribute. +`PreferWriteOnlyAttribute()` is a validator that takes a `cty.Path` to an existing configuration attribute (required/optional) and a `cty.Path` to a write-only argument. Use this validator when you have a write-only version of an existing attribute, and you want to encourage practitioners to use the write-only version whenever possible. @@ -173,8 +173,8 @@ When using `cty.Path` to traverse into a nested block, use an unknown value to i ## Best Practices -Since write-only attributes have no prior values, user intent cannot be determined with a write-only attribute alone. To determine when to use/not use a write-only attribute value in your provider, we recommend using other non-write-only attributes in the provider. For example: +Since write-only arguments have no prior values, user intent cannot be determined with a write-only argument alone. To determine when to use/not use a write-only argument value in your provider, we recommend using other non-write-only arguments in the provider. For example: -- Pair write-only attributes with a configuration attribute (required or optional) to “trigger” the use of the write-only attribute - - For example, a `password_wo` write-only attribute can be paired with a configured `password_wo_version` attribute. When the `password_wo_version` is modified, the provider will send the `password_wo` value to the API. -- Use a keepers attribute (which is used in the [Random Provider](https://registry.terraform.io/providers/hashicorp/random/latest/docs#resource-keepers)) that will take in arbitrary key-pair values. Whenever there is a change to the `keepers` attribute, the provider will use the write-only attribute value. \ No newline at end of file +- Pair write-only arguments with a configuration attribute (required or optional) to “trigger” the use of the write-only argument + - For example, a `password_wo` write-only argument can be paired with a configured `password_wo_version` attribute. When the `password_wo_version` is modified, the provider will send the `password_wo` value to the API. +- Use a keepers attribute (which is used in the [Random Provider](https://registry.terraform.io/providers/hashicorp/random/latest/docs#resource-keepers)) that will take in arbitrary key-pair values. Whenever there is a change to the `keepers` attribute, the provider will use the write-only argument value. \ No newline at end of file diff --git a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx index dc530914da6..9e98a4abbbf 100644 --- a/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx +++ b/website/docs/plugin/sdkv2/schemas/schema-behaviors.mdx @@ -199,7 +199,7 @@ resource "example_instance" "ex" { ### WriteOnly -~> **NOTE:** Write-only attributes are only supported in Terraform `v1.11` or higher +~> **NOTE:** Write-only arguments are only supported in Terraform `v1.11` or higher **Data structure:** [bool](https://pkg.go.dev/builtin#bool) @@ -213,12 +213,12 @@ resource "example_instance" "ex" { - Cannot be used when `ForceNew` is `true` - Cannot be used when `Default` is `specified` - Cannot be used with `DefaultFunc` -- Cannot be used with aggregate schema types (e.g. `typeMap`, `typeList`, `typeSet`) +- Cannot be used with aggregate schema types (e.g. `typeMap`, `typeList`, `typeSet`), but non-computed nested block types can contain write-only arguments. -`WriteOnly` should be used for attributes that handle secret values that do not need to be persisted in Terraform plan or state, -such as passwords, API keys, etc. Write-only attribute values are not sent to Terraform and do not +`WriteOnly` should be used for arguments that handle secret values that do not need to be persisted in Terraform plan or state, +such as passwords, API keys, etc. Write-only argument values are not sent to Terraform and do not persist in the Terraform plan or state artifacts. -Attributes marked +Arguments marked as `WriteOnly` can accept [ephemeral values](/terraform/language/resources/ephemeral). **Schema example:** From 099130f4e76a3dde95d797ca1489a533cec15620 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 3 Feb 2025 15:15:02 -0500 Subject: [PATCH 57/58] Add configuration examples to `cty.Path` documentation --- .../sdkv2/resources/write-only-arguments.mdx | 98 ++++++++++++++++--- 1 file changed, 83 insertions(+), 15 deletions(-) diff --git a/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx b/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx index 986655c447c..1ed7faa6cf7 100644 --- a/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx +++ b/website/docs/plugin/sdkv2/resources/write-only-arguments.mdx @@ -72,45 +72,113 @@ This is very similar to the `terraform-plugin-framework` [paths](/terraform/plug All top level attributes or blocks can be referred to using `cty.GetAttrPath()` +**Configuration example:** + +```hcl +resource "example_resource" "example" { + "top_level_schema_attribute" = 1 +} +``` + +**Path example:** + ```go -cty.GetAttrPath("top_level_schema_attribute") +cty.GetAttrPath("top_level_schema_attribute") // returns cty.NumberIntVal(1) +``` + +Maps can be traversed using `IndexString()` + +**Configuration example:** + +```hcl +resource "example_resource" "example" { + map_attribute { + key1 = "value1" + } +} ``` -Maps or map nested blocks can be traversed using `IndexString()` +**Path example:** ```go // Map traversal -cty.GetAttrPath("map_attribute").IndexString("key1") +cty.GetAttrPath("map_attribute").IndexString("key1") // returns cty.StringVal("value1") +``` +Lists or list nested blocks can be traversed using `IndexInt()` -// Map nested block traversal -cty.GetAttrPath("map_nested_block").IndexString("block1").getAttr("map_nested_block_attribute") +**Configuration example:** +```hcl +resource "example_resource" "example" { + list_attribute = ["value1", "value2"] + list_nested_block { + list_nested_block_attribute = "value3" + } + + list_nested_block { + list_nested_block_attribute = "value4" + } +} ``` -Lists or list nested blocks can be traversed using `IndexInt()` +**Path example:** ```go // List traversal -cty.GetAttrPath("list_attribute").IndexInt(0) +cty.GetAttrPath("list_attribute").IndexInt(0) // returns cty.StringVal("value1") // List nested block traversal -cty.GetAttrPath("list_nested_block").IndexInt(1).getAttr("list_nested_block_attribute") +cty.GetAttrPath("list_nested_block").IndexInt(1).getAttr("list_nested_block_attribute") // returns cty.StringVal("value4") ``` Sets or set nested blocks can be traversed using `Index()`. `Index()` takes in a `cty.Value` of the set element that you want to traverse into. -```go -// Set traversal -cty.GetAttrPath("set_attribute").Index(cty.String("set_string_val")) +However, if you do not know the specific value of the desired set element, +you can also retrieve the entire set using `cty.GetAttrPath()`. +**Configuration example:** -// Set nested block traversal -cty.GetAttrPath("set_nested_block"). -Index(cty.ObjectVal(map[string]cty.Value{"set_nested_block_attribute": cty.StringVal("valueA")})) -.GetAttr("set_nested_block_attribute") // returns path to cty.String("valueA") +```hcl +resource "example_resource" "example" { + set_attribute = ["value1", "value2"] + set_nested_block { + set_nested_block_attribute = "value3" + } + + set_nested_block { + set_nested_block_attribute = "value4" + } +} +``` +**Path example:** + +```go +// Set attribute - root traversal +cty.GetAttrPath("set_attribute") // returns cty.SetVal([]cty.Value{cty.StringVal("value1"), cty.StringVal("value2")}) + +// Set attribute - index traversal +cty.GetAttrPath("set_attribute").Index(cty.StringVal("value2")) // returns cty.StringVal("value2") + + +// Set nested block - root traversal +cty.GetAttrPath("set_nested_block") +// returns: +// cty.SetVal([]cty.Value{ +// cty.ObjectVal(map[string]cty.Value{ +// "set_nested_block_attribute": cty.StringVal("value3"), +// }), +// cty.ObjectVal(map[string]cty.Value{ +// "set_nested_block_attribute": cty.StringVal("value4"), +// }), +// }), + +// Set nested block - index traversal +cty.GetAttrPath("set_nested_block") +.Index(cty.ObjectVal(map[string]cty.Value{"set_nested_block_attribute": cty.StringVal("value4")})) +.GetAttr("set_nested_block_attribute") // returns cty.String("value4") ``` ### cty.Value From e58d5e3f416cb671ba925eb3e48b728068afb7c1 Mon Sep 17 00:00:00 2001 From: Selena Goods Date: Mon, 3 Feb 2025 15:20:22 -0500 Subject: [PATCH 58/58] Add changelog entry for `ValidateRawResourceConfigFuncs` --- .changes/unreleased/FEATURES-20250203-151933.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/unreleased/FEATURES-20250203-151933.yaml diff --git a/.changes/unreleased/FEATURES-20250203-151933.yaml b/.changes/unreleased/FEATURES-20250203-151933.yaml new file mode 100644 index 00000000000..488d65d4f19 --- /dev/null +++ b/.changes/unreleased/FEATURES-20250203-151933.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'schema/resource: Added `ValidateRawResourceConfigFuncs` field which allows resources to define validation logic during the `ValidateResourceTypeConfig` RPC.' +time: 2025-02-03T15:19:33.669857-05:00 +custom: + Issue: "1375"