diff --git a/internal/command/query_test.go b/internal/command/query_test.go index 66ed039c784b..6efffba2c80b 100644 --- a/internal/command/query_test.go +++ b/internal/command/query_test.go @@ -270,7 +270,9 @@ func queryFixtureProvider() *testing_provider.MockProvider { }, }, }, - Nesting: configschema.NestingSingle, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, }, }, } @@ -291,7 +293,9 @@ func queryFixtureProvider() *testing_provider.MockProvider { }, }, }, - Nesting: configschema.NestingSingle, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, }, }, } diff --git a/internal/plugin/convert/schema.go b/internal/plugin/convert/schema.go index d07f91645fd7..d3b003d561ae 100644 --- a/internal/plugin/convert/schema.go +++ b/internal/plugin/convert/schema.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/providers" proto "github.com/hashicorp/terraform/internal/tfplugin5" + "github.com/zclconf/go-cty/cty" ) // ConfigSchemaToProto takes a *configschema.Block and converts it to a @@ -111,6 +112,45 @@ func ProtoToActionSchema(s *proto.ActionSchema) providers.ActionSchema { } } +func ProtoToListSchema(s *proto.Schema) providers.Schema { + listSchema := ProtoToProviderSchema(s, nil) + itemCount := 0 + // check if the provider has set some attributes/blocks as required. + // When yes, then we set minItem = 1, which + // validates that the configuration contains a "config" block. + for _, attrS := range listSchema.Body.Attributes { + if attrS.Required { + itemCount = 1 + break + } + } + for _, block := range listSchema.Body.BlockTypes { + if block.MinItems > 0 { + itemCount = 1 + break + } + } + return providers.Schema{ + Version: s.Version, + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "data": { + Type: cty.DynamicPseudoType, + Computed: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "config": { + Block: *listSchema.Body, + Nesting: configschema.NestingSingle, + MinItems: itemCount, + MaxItems: itemCount, + }, + }, + }, + } +} + // ProtoToConfigSchema takes the GetSchcema_Block from a grpc response and converts it // to a terraform *configschema.Block. func ProtoToConfigSchema(b *proto.Schema_Block) *configschema.Block { diff --git a/internal/plugin/grpc_provider.go b/internal/plugin/grpc_provider.go index c3c0793dc27c..3f8251b4a11d 100644 --- a/internal/plugin/grpc_provider.go +++ b/internal/plugin/grpc_provider.go @@ -20,7 +20,6 @@ import ( "google.golang.org/grpc/status" "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plugin/convert" "github.com/hashicorp/terraform/internal/providers" @@ -171,24 +170,7 @@ func (p *GRPCProvider) GetProviderSchema() providers.GetProviderSchemaResponse { } for name, list := range protoResp.ListResourceSchemas { - ret := convert.ProtoToProviderSchema(list, nil) - resp.ListResourceTypes[name] = providers.Schema{ - Version: ret.Version, - Body: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "data": { - Type: cty.DynamicPseudoType, - Computed: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "config": { - Block: *ret.Body, - Nesting: configschema.NestingSingle, - }, - }, - }, - } + resp.ListResourceTypes[name] = convert.ProtoToListSchema(list) } for name, action := range protoResp.ActionSchemas { @@ -381,10 +363,12 @@ func (p *GRPCProvider) ValidateListResourceConfig(r providers.ValidateListResour } configSchema := listResourceSchema.Body.BlockTypes["config"] - config := cty.NullVal(configSchema.ImpliedType()) - if r.Config.Type().HasAttribute("config") { - config = r.Config.GetAttr("config") + if !r.Config.Type().HasAttribute("config") { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("missing required attribute \"config\"; this is a bug in Terraform - please report it")) + return resp } + + config := r.Config.GetAttr("config") mp, err := msgpack.Marshal(config, configSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) @@ -1342,10 +1326,12 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L } configSchema := listResourceSchema.Body.BlockTypes["config"] - config := cty.NullVal(configSchema.ImpliedType()) - if r.Config.Type().HasAttribute("config") { - config = r.Config.GetAttr("config") + if !r.Config.Type().HasAttribute("config") { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("missing required attribute \"config\"; this is a bug in Terraform - please report it")) + return resp } + + config := r.Config.GetAttr("config") mp, err := msgpack.Marshal(config, configSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) diff --git a/internal/plugin/grpc_provider_test.go b/internal/plugin/grpc_provider_test.go index 657b8b7f33d3..34017d724406 100644 --- a/internal/plugin/grpc_provider_test.go +++ b/internal/plugin/grpc_provider_test.go @@ -146,6 +146,23 @@ func providerProtoSchema() *proto.GetProviderSchema_Response { Required: true, }, }, + BlockTypes: []*proto.Schema_NestedBlock{ + { + TypeName: "nested_filter", + Nesting: proto.Schema_NestedBlock_SINGLE, + Block: &proto.Schema_Block{ + Attributes: []*proto.Schema_Attribute{ + { + Name: "nested_attr", + Type: []byte(`"string"`), + Required: false, + }, + }, + }, + MinItems: 1, + MaxItems: 1, + }, + }, }, }, }, @@ -466,7 +483,7 @@ func TestGRPCProvider_ValidateListResourceConfig(t *testing.T) { gomock.Any(), ).Return(&proto.ValidateListResourceConfig_Response{}, nil) - cfg := hcl2shim.HCL2ValueFromConfigValue(map[string]interface{}{"config": map[string]interface{}{"filter_attr": "value"}}) + cfg := hcl2shim.HCL2ValueFromConfigValue(map[string]interface{}{"config": map[string]interface{}{"filter_attr": "value", "nested_filter": map[string]interface{}{"nested_attr": "value"}}}) resp := p.ValidateListResourceConfig(providers.ValidateListResourceConfigRequest{ TypeName: "list", Config: cfg, @@ -478,8 +495,18 @@ func TestGRPCProvider_ValidateListResourceConfig_OptionalCfg(t *testing.T) { ctrl := gomock.NewController(t) client := mockproto.NewMockProviderClient(ctrl) sch := providerProtoSchema() - sch.ListResourceSchemas["list"].Block.Attributes[0].Optional = true - sch.ListResourceSchemas["list"].Block.Attributes[0].Required = false + + // mock the schema in a way that makes the config attributes optional + listSchema := sch.ListResourceSchemas["list"].Block + // filter_attr is optional + listSchema.Attributes[0].Optional = true + listSchema.Attributes[0].Required = false + + // nested_filter is optional + listSchema.BlockTypes[0].MinItems = 0 + listSchema.BlockTypes[0].MaxItems = 0 + + sch.ListResourceSchemas["list"].Block = listSchema // we always need a GetSchema method client.EXPECT().GetSchema( gomock.Any(), @@ -502,10 +529,15 @@ func TestGRPCProvider_ValidateListResourceConfig_OptionalCfg(t *testing.T) { gomock.Any(), ).Return(&proto.ValidateListResourceConfig_Response{}, nil) - cfg := hcl2shim.HCL2ValueFromConfigValue(map[string]interface{}{}) + converted := convert.ProtoToListSchema(sch.ListResourceSchemas["list"]) + cfg := hcl2shim.HCL2ValueFromConfigValue(map[string]any{}) + coercedCfg, err := converted.Body.CoerceValue(cfg) + if err != nil { + t.Fatalf("failed to coerce config: %v", err) + } resp := p.ValidateListResourceConfig(providers.ValidateListResourceConfigRequest{ TypeName: "list", - Config: cfg, + Config: coercedCfg, }) checkDiags(t, resp.Diagnostics) } @@ -1438,8 +1470,25 @@ func TestGRPCProvider_GetSchema_ListResourceTypes(t *testing.T) { Required: true, }, }, + BlockTypes: map[string]*configschema.NestedBlock{ + "nested_filter": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "nested_attr": { + Type: cty.String, + Required: false, + }, + }, + }, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, + }, + }, }, - Nesting: configschema.NestingSingle, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, }, }, }, @@ -1485,6 +1534,9 @@ func TestGRPCProvider_Encode(t *testing.T) { Before: cty.NullVal(cty.Object(map[string]cty.Type{ "config": cty.Object(map[string]cty.Type{ "filter_attr": cty.String, + "nested_filter": cty.Object(map[string]cty.Type{ + "nested_attr": cty.String, + }), }), "data": cty.List(cty.Object(map[string]cty.Type{ "state": cty.Object(map[string]cty.Type{ @@ -1498,6 +1550,9 @@ func TestGRPCProvider_Encode(t *testing.T) { After: cty.ObjectVal(map[string]cty.Value{ "config": cty.ObjectVal(map[string]cty.Value{ "filter_attr": cty.StringVal("value"), + "nested_filter": cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + }), }), "data": cty.ListVal([]cty.Value{ cty.ObjectVal(map[string]cty.Value{ @@ -1649,6 +1704,9 @@ func TestGRPCProvider_ListResource(t *testing.T) { configVal := cty.ObjectVal(map[string]cty.Value{ "config": cty.ObjectVal(map[string]cty.Value{ "filter_attr": cty.StringVal("filter-value"), + "nested_filter": cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + }), }), }) request := providers.ListResourceRequest{ @@ -1731,6 +1789,9 @@ func TestGRPCProvider_ListResource_Error(t *testing.T) { configVal := cty.ObjectVal(map[string]cty.Value{ "config": cty.ObjectVal(map[string]cty.Value{ "filter_attr": cty.StringVal("filter-value"), + "nested_filter": cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + }), }), }) request := providers.ListResourceRequest{ @@ -1746,6 +1807,9 @@ func TestGRPCProvider_ListResource_Diagnostics(t *testing.T) { configVal := cty.ObjectVal(map[string]cty.Value{ "config": cty.ObjectVal(map[string]cty.Value{ "filter_attr": cty.StringVal("filter-value"), + "nested_filter": cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + }), }), }) request := providers.ListResourceRequest{ @@ -2009,6 +2073,9 @@ func TestGRPCProvider_ListResource_Limit(t *testing.T) { configVal := cty.ObjectVal(map[string]cty.Value{ "config": cty.ObjectVal(map[string]cty.Value{ "filter_attr": cty.StringVal("filter-value"), + "nested_filter": cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), + }), }), }) request := providers.ListResourceRequest{ diff --git a/internal/plugin6/convert/schema.go b/internal/plugin6/convert/schema.go index a762590862ca..4ea6a8faf507 100644 --- a/internal/plugin6/convert/schema.go +++ b/internal/plugin6/convert/schema.go @@ -118,6 +118,45 @@ func ProtoToActionSchema(s *proto.ActionSchema) providers.ActionSchema { } } +func ProtoToListSchema(s *proto.Schema) providers.Schema { + listSchema := ProtoToProviderSchema(s, nil) + itemCount := 0 + // check if the provider has set some attributes/blocks as required. + // When yes, then we set minItem = 1, which + // validates that the configuration contains a "config" block. + for _, attrS := range listSchema.Body.Attributes { + if attrS.Required { + itemCount = 1 + break + } + } + for _, block := range listSchema.Body.BlockTypes { + if block.MinItems > 0 { + itemCount = 1 + break + } + } + return providers.Schema{ + Version: listSchema.Version, + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "data": { + Type: cty.DynamicPseudoType, + Computed: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "config": { + Block: *listSchema.Body, + Nesting: configschema.NestingSingle, + MinItems: itemCount, + MaxItems: itemCount, + }, + }, + }, + } +} + func ProtoToIdentitySchema(attributes []*proto.ResourceIdentitySchema_IdentityAttribute) *configschema.Object { obj := &configschema.Object{ Attributes: make(map[string]*configschema.Attribute), diff --git a/internal/plugin6/grpc_provider.go b/internal/plugin6/grpc_provider.go index b991ed6b8d0f..0ab7da98b1a2 100644 --- a/internal/plugin6/grpc_provider.go +++ b/internal/plugin6/grpc_provider.go @@ -20,7 +20,6 @@ import ( "google.golang.org/grpc/status" "github.com/hashicorp/terraform/internal/addrs" - "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/logging" "github.com/hashicorp/terraform/internal/plugin6/convert" "github.com/hashicorp/terraform/internal/providers" @@ -172,24 +171,7 @@ func (p *GRPCProvider) GetProviderSchema() providers.GetProviderSchemaResponse { } for name, list := range protoResp.ListResourceSchemas { - ret := convert.ProtoToProviderSchema(list, nil) - resp.ListResourceTypes[name] = providers.Schema{ - Version: ret.Version, - Body: &configschema.Block{ - Attributes: map[string]*configschema.Attribute{ - "data": { - Type: cty.DynamicPseudoType, - Computed: true, - }, - }, - BlockTypes: map[string]*configschema.NestedBlock{ - "config": { - Block: *ret.Body, - Nesting: configschema.NestingSingle, - }, - }, - }, - } + resp.ListResourceTypes[name] = convert.ProtoToListSchema(list) } for name, store := range protoResp.StateStoreSchemas { @@ -377,11 +359,14 @@ func (p *GRPCProvider) ValidateListResourceConfig(r providers.ValidateListResour resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("unknown list resource type %q", r.TypeName)) return resp } + configSchema := listResourceSchema.Body.BlockTypes["config"] - config := cty.NullVal(configSchema.ImpliedType()) - if r.Config.Type().HasAttribute("config") { - config = r.Config.GetAttr("config") + if !r.Config.Type().HasAttribute("config") { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("missing required attribute \"config\"; this is a bug in Terraform - please report it")) + return resp } + + config := r.Config.GetAttr("config") mp, err := msgpack.Marshal(config, configSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) @@ -1337,10 +1322,12 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L } configSchema := listResourceSchema.Body.BlockTypes["config"] - config := cty.NullVal(configSchema.ImpliedType()) - if r.Config.Type().HasAttribute("config") { - config = r.Config.GetAttr("config") + if !r.Config.Type().HasAttribute("config") { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("missing required attribute \"config\"; this is a bug in Terraform - please report it")) + return resp } + + config := r.Config.GetAttr("config") mp, err := msgpack.Marshal(config, configSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) diff --git a/internal/plugin6/grpc_provider_test.go b/internal/plugin6/grpc_provider_test.go index 5e375c66ce53..44fb722dcff3 100644 --- a/internal/plugin6/grpc_provider_test.go +++ b/internal/plugin6/grpc_provider_test.go @@ -522,10 +522,15 @@ func TestGRPCProvider_ValidateListResourceConfig_OptionalCfg(t *testing.T) { gomock.Any(), ).Return(&proto.ValidateListResourceConfig_Response{}, nil) + converted := convert.ProtoToListSchema(sch.ListResourceSchemas["list"]) cfg := hcl2shim.HCL2ValueFromConfigValue(map[string]interface{}{}) + coercedCfg, err := converted.Body.CoerceValue(cfg) + if err != nil { + t.Fatalf("failed to coerce config: %v", err) + } resp := p.ValidateListResourceConfig(providers.ValidateListResourceConfigRequest{ TypeName: "list", - Config: cfg, + Config: coercedCfg, }) checkDiags(t, resp.Diagnostics) } @@ -1458,7 +1463,9 @@ func TestGRPCProvider_GetSchema_ListResourceTypes(t *testing.T) { }, }, }, - Nesting: configschema.NestingSingle, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, }, }, }, diff --git a/internal/providers/provider.go b/internal/providers/provider.go index b19c1451a501..579626c3699f 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -220,6 +220,19 @@ func (a ActionSchema) IsNil() bool { return a.ConfigSchema == nil } +type ListResourceSchema struct { + // schema for the nested "config" block. + ConfigSchema *configschema.Block + + // schema for the entire block (including "config" block) + FullSchema *configschema.Block +} + +// IsNil() returns true if there is no list resource schema at all. +func (l ListResourceSchema) IsNil() bool { + return l.FullSchema == nil +} + // Schema pairs a provider or resource schema with that schema's version. // This is used to be able to upgrade the schema in UpgradeResourceState. // diff --git a/internal/providers/schemas.go b/internal/providers/schemas.go index 4d02c8bdc897..9cb64b5ecfef 100644 --- a/internal/providers/schemas.go +++ b/internal/providers/schemas.go @@ -47,3 +47,20 @@ func (ss ProviderSchema) SchemaForActionType(typeName string) (schema ActionSche } return ActionSchema{} } + +// SchemaForListResourceType attempts to find a schema for the given type. Returns an +// empty schema if none is available. +func (ss ProviderSchema) SchemaForListResourceType(typeName string) ListResourceSchema { + schema, ok := ss.ListResourceTypes[typeName] + ret := ListResourceSchema{FullSchema: schema.Body} + if !ok || schema.Body == nil { + return ret + } + // The configuration for the list block is nested within a "config" block. + configSchema, ok := schema.Body.BlockTypes["config"] + if !ok { + return ret + } + ret.ConfigSchema = &configSchema.Block + return ret +} diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index 3f95027dcd26..7578fe0c1d8e 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -12,6 +12,7 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/plans" @@ -24,15 +25,16 @@ import ( func TestContext2Plan_queryList(t *testing.T) { cases := []struct { - name string - mainConfig string - queryConfig string - generatedPath string - diagCount int - expectedErrMsg []string - assertState func(*states.State) - assertChanges func(providers.ProviderSchema, *plans.ChangesSrc) - listResourceFn func(request providers.ListResourceRequest) providers.ListResourceResponse + name string + mainConfig string + queryConfig string + generatedPath string + expectedErrMsg []string + transformSchema func(*providers.GetProviderSchemaResponse) + assertState func(*states.State) + assertValidateDiags func(t *testing.T, diags tfdiags.Diagnostics) + assertChanges func(providers.ProviderSchema, *plans.ChangesSrc) + listResourceFn func(request providers.ListResourceRequest) providers.ListResourceResponse }{ { name: "valid list reference - generates config", @@ -261,6 +263,186 @@ func TestContext2Plan_queryList(t *testing.T) { } }, }, + { + name: "with empty config when it is required", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + variable "input" { + type = string + default = "foo" + } + + list "test_resource" "test" { + provider = test + } + `, + + transformSchema: func(schema *providers.GetProviderSchemaResponse) { + schema.ListResourceTypes["test_resource"].Body.BlockTypes = map[string]*configschema.NestedBlock{ + "config": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "filter": { + Required: true, + NestedType: &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + }, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, + }, + } + + }, + assertValidateDiags: func(t *testing.T, diags tfdiags.Diagnostics) { + tfdiags.AssertDiagnosticCount(t, diags, 1) + var exp tfdiags.Diagnostics + exp = exp.Append(&hcl.Diagnostic{ + Summary: "Missing config block", + Detail: "A block of type \"config\" is required here.", + Subject: diags[0].Source().Subject.ToHCL().Ptr(), + }) + tfdiags.AssertDiagnosticsMatch(t, diags, exp) + }, + }, + { + name: "with empty optional config", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + variable "input" { + type = string + default = "foo" + } + + list "test_resource" "test" { + provider = test + } + `, + transformSchema: func(schema *providers.GetProviderSchemaResponse) { + schema.ListResourceTypes["test_resource"].Body.BlockTypes = map[string]*configschema.NestedBlock{ + "config": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "filter": { + Optional: true, + NestedType: &configschema.Object{ + Nesting: configschema.NestingSingle, + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + }, + Nesting: configschema.NestingSingle, + }, + } + + }, + listResourceFn: func(request providers.ListResourceRequest) providers.ListResourceResponse { + madeUp := []cty.Value{ + cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-123456")}), + cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-654321")}), + } + ids := []cty.Value{} + for i := range madeUp { + ids = append(ids, cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal(fmt.Sprintf("i-v%d", i+1)), + })) + } + + resp := []cty.Value{} + for i, v := range madeUp { + resp = append(resp, cty.ObjectVal(map[string]cty.Value{ + "state": v, + "identity": ids[i], + "display_name": cty.StringVal(fmt.Sprintf("Instance %d", i+1)), + })) + } + + ret := map[string]cty.Value{ + "data": cty.TupleVal(resp), + } + for k, v := range request.Config.AsValueMap() { + if k != "data" { + ret[k] = v + } + } + + return providers.ListResourceResponse{Result: cty.ObjectVal(ret)} + }, + assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { + expectedResources := []string{"list.test_resource.test"} + actualResources := make([]string, 0) + for _, change := range changes.Queries { + actualResources = append(actualResources, change.Addr.String()) + schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] + cs, err := change.Decode(schema) + if err != nil { + t.Fatalf("failed to decode change: %s", err) + } + + // Verify instance types + expectedTypes := []string{"ami-123456", "ami-654321"} + actualTypes := make([]string, 0) + obj := cs.Results.Value.GetAttr("data") + if obj.IsNull() { + t.Fatalf("Expected 'data' attribute to be present, but it is null") + } + obj.ForEachElement(func(key cty.Value, val cty.Value) bool { + val = val.GetAttr("state") + if val.IsNull() { + t.Fatalf("Expected 'state' attribute to be present, but it is null") + } + if val.GetAttr("instance_type").IsNull() { + t.Fatalf("Expected 'instance_type' attribute to be present, but it is missing") + } + actualTypes = append(actualTypes, val.GetAttr("instance_type").AsString()) + return false + }) + sort.Strings(actualTypes) + sort.Strings(expectedTypes) + if diff := cmp.Diff(expectedTypes, actualTypes); diff != "" { + t.Fatalf("Expected instance types to match, but they differ: %s", diff) + } + } + sort.Strings(actualResources) + sort.Strings(expectedResources) + if diff := cmp.Diff(expectedResources, actualResources); diff != "" { + t.Fatalf("Expected resources to match, but they differ: %s", diff) + } + }, + }, { name: "invalid list result's attribute reference", mainConfig: ` @@ -301,10 +483,17 @@ func TestContext2Plan_queryList(t *testing.T) { } } `, - diagCount: 1, - expectedErrMsg: []string{ - "Invalid list resource traversal", - "The first step in the traversal for a list resource must be an attribute \"data\"", + assertValidateDiags: func(t *testing.T, diags tfdiags.Diagnostics) { + tfdiags.AssertDiagnosticCount(t, diags, 1) + var exp tfdiags.Diagnostics + exp = exp.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Invalid list resource traversal", + Detail: "The first step in the traversal for a list resource must be an attribute \"data\".", + Subject: diags[0].Source().Subject.ToHCL().Ptr(), + }) + + tfdiags.AssertDiagnosticsMatch(t, diags, exp) }, }, { @@ -332,9 +521,18 @@ func TestContext2Plan_queryList(t *testing.T) { } } `, - diagCount: 1, - expectedErrMsg: []string{ - "A list resource \"non_existent\" \"attr\" has not been declared in the root module.", + assertValidateDiags: func(t *testing.T, diags tfdiags.Diagnostics) { + tfdiags.AssertDiagnosticCount(t, diags, 1) + var exp tfdiags.Diagnostics + + exp = exp.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Reference to undeclared resource", + Detail: "A list resource \"non_existent\" \"attr\" has not been declared in the root module.", + Subject: diags[0].Source().Subject.ToHCL().Ptr(), + }) + + tfdiags.AssertDiagnosticsMatch(t, diags, exp) }, }, { @@ -373,9 +571,18 @@ func TestContext2Plan_queryList(t *testing.T) { } } `, - diagCount: 1, - expectedErrMsg: []string{ - "Unsupported attribute: This object has no argument, nested block, or exported attribute named \"invalid_attr\".", + assertValidateDiags: func(t *testing.T, diags tfdiags.Diagnostics) { + tfdiags.AssertDiagnosticCount(t, diags, 1) + var exp tfdiags.Diagnostics + + exp = exp.Append(&hcl.Diagnostic{ + Severity: hcl.DiagError, + Summary: "Unsupported attribute", + Detail: "This object has no argument, nested block, or exported attribute named \"invalid_attr\".", + Subject: diags[0].Source().Subject.ToHCL().Ptr(), + }) + + tfdiags.AssertDiagnosticsMatch(t, diags, exp) }, listResourceFn: func(request providers.ListResourceRequest) providers.ListResourceResponse { madeUp := []cty.Value{ @@ -444,9 +651,14 @@ func TestContext2Plan_queryList(t *testing.T) { } } `, - diagCount: 1, - expectedErrMsg: []string{ - "Cycle: list.test_resource", + assertValidateDiags: func(t *testing.T, diags tfdiags.Diagnostics) { + tfdiags.AssertDiagnosticCount(t, diags, 1) + if !strings.Contains(diags[0].Description().Summary, "Cycle: list.test_resource") { + t.Errorf("Expected error message to contain 'Cycle: list.test_resource', got %q", diags[0].Description().Summary) + } + if diags[0].Severity() != tfdiags.Error { + t.Errorf("Expected error severity to be Error, got %s", diags[0].Severity()) + } }, }, { @@ -564,8 +776,7 @@ func TestContext2Plan_queryList(t *testing.T) { }, }, { - // Test list reference with index but without data field - name: "list reference with index but without data field", + name: "list reference as for_each", mainConfig: ` terraform { required_providers { @@ -688,8 +899,14 @@ func TestContext2Plan_queryList(t *testing.T) { provider := testProvider("test") provider.ConfigureProvider(providers.ConfigureProviderRequest{}) provider.GetProviderSchemaResponse = getListProviderSchemaResp() + if tc.transformSchema != nil { + tc.transformSchema(provider.GetProviderSchemaResponse) + } var requestConfigs = make(map[string]cty.Value) provider.ListResourceFn = func(request providers.ListResourceRequest) providers.ListResourceResponse { + if request.Config.IsNull() || request.Config.GetAttr("config").IsNull() { + t.Fatalf("config should never be null, got null for %s", request.TypeName) + } requestConfigs[request.TypeName] = request.Config fn := tc.listResourceFn if fn == nil { @@ -705,15 +922,21 @@ func TestContext2Plan_queryList(t *testing.T) { }) tfdiags.AssertNoDiagnostics(t, diags) + diags = ctx.Validate(mod, &ValidateOpts{}) + if tc.assertValidateDiags != nil { + tc.assertValidateDiags(t, diags) + return + } else { + tfdiags.AssertNoDiagnostics(t, diags) + } + plan, diags := ctx.Plan(mod, states.NewState(), &PlanOpts{ Mode: plans.NormalMode, SetVariables: testInputValuesUnset(mod.Module.Variables), Query: true, GenerateConfigPath: tc.generatedPath, }) - if len(diags) != tc.diagCount { - t.Fatalf("expected %d diagnostics, got %d \n -diags: %s", tc.diagCount, len(diags), diags) - } + tfdiags.AssertNoDiagnostics(t, diags) if tc.assertChanges != nil { sch, err := ctx.Schemas(mod, states.NewState()) @@ -722,15 +945,6 @@ func TestContext2Plan_queryList(t *testing.T) { } tc.assertChanges(sch.Providers[providerAddr], plan.Changes) } - - if tc.diagCount > 0 { - for _, err := range tc.expectedErrMsg { - if !strings.Contains(diags.Err().Error(), err) { - t.Fatalf("expected error message %q, but got %q", err, diags.Err().Error()) - } - } - } - }) } } @@ -837,6 +1051,9 @@ func TestContext2Plan_queryListArgs(t *testing.T) { provider.GetProviderSchemaResponse = getListProviderSchemaResp() var recordedRequest providers.ListResourceRequest provider.ListResourceFn = func(request providers.ListResourceRequest) providers.ListResourceResponse { + if request.Config.IsNull() || request.Config.GetAttr("config").IsNull() { + t.Fatalf("config should never be null, got null for %s", request.TypeName) + } recordedRequest = request return provider.ListResourceResponse } diff --git a/internal/terraform/evaluate_valid.go b/internal/terraform/evaluate_valid.go index 16315abd5da5..9ce292d25153 100644 --- a/internal/terraform/evaluate_valid.go +++ b/internal/terraform/evaluate_valid.go @@ -303,7 +303,7 @@ func staticValidateResourceReference(modCfg *configs.Config, addr addrs.Resource diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid list resource traversal`, - Detail: fmt.Sprintf(`The first step in the traversal for a %s resource must be an attribute "data", but got %q instead.`, modeAdjective, remain[0]), + Detail: fmt.Sprintf(`The first step in the traversal for a %s resource must be an attribute "data".`, modeAdjective), Subject: rng.ToHCL().Ptr(), }) return diags @@ -315,7 +315,7 @@ func staticValidateResourceReference(modCfg *configs.Config, addr addrs.Resource diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid list resource traversal`, - Detail: fmt.Sprintf(`The second step in the traversal for a %s resource must be an index, but got %q instead.`, modeAdjective, remain[0]), + Detail: fmt.Sprintf(`The second step in the traversal for a %s resource must be an index.`, modeAdjective), Subject: rng.ToHCL().Ptr(), }) return diags @@ -331,7 +331,7 @@ func staticValidateResourceReference(modCfg *configs.Config, addr addrs.Resource diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid list resource traversal`, - Detail: fmt.Sprintf(`The third step in the traversal for a %s resource must be an attribute "state" or "identity", but got %q instead.`, modeAdjective, remain[0]), + Detail: fmt.Sprintf(`The third step in the traversal for a %s resource must be an attribute "state" or "identity".`, modeAdjective), Subject: rng.ToHCL().Ptr(), }) return diags @@ -340,7 +340,7 @@ func staticValidateResourceReference(modCfg *configs.Config, addr addrs.Resource diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: `Invalid list resource traversal`, - Detail: fmt.Sprintf(`The third step in the traversal for a %s resource must be an attribute "state" or "identity", but got %q instead.`, modeAdjective, stateOrIdent.Name), + Detail: fmt.Sprintf(`The third step in the traversal for a %s resource must be an attribute "state" or "identity".`, modeAdjective), Subject: rng.ToHCL().Ptr(), }) return diags diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go index 2a517f3af8c4..c1ddf131fcb6 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -36,9 +36,15 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di keyData = EvalDataForInstanceKey(addr.Resource.Key, forEach) } + schema := providerSchema.SchemaForListResourceType(n.Config.Type) + if schema.IsNil() { // Not possible, as the schema should have already been validated to exist + diags = diags.Append(fmt.Errorf("no schema available for %s; this is a bug in Terraform and should be reported", addr)) + return diags + } + // evaluate the list config block var configDiags tfdiags.Diagnostics - blockVal, _, configDiags := ctx.EvaluateBlock(config.Config, n.Schema.Body, nil, keyData) + blockVal, _, configDiags := ctx.EvaluateBlock(config.Config, schema.FullSchema, nil, keyData) diags = diags.Append(configDiags) if diags.HasErrors() { return diags @@ -79,6 +85,13 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di } log.Printf("[TRACE] NodePlannableResourceInstance: Re-validating config for %s", n.Addr) + // if the config value is null, we still want to send a full object with all attributes being null + if !unmarkedBlockVal.IsNull() && unmarkedBlockVal.GetAttr("config").IsNull() { + mp := unmarkedBlockVal.AsValueMap() + mp["config"] = schema.ConfigSchema.EmptyValue() + unmarkedBlockVal = cty.ObjectVal(mp) + } + validateResp := provider.ValidateListResourceConfig( providers.ValidateListResourceConfigRequest{ TypeName: n.Config.Type, diff --git a/internal/terraform/node_resource_validate.go b/internal/terraform/node_resource_validate.go index 8d106bc16856..7dd60e24c177 100644 --- a/internal/terraform/node_resource_validate.go +++ b/internal/terraform/node_resource_validate.go @@ -471,8 +471,8 @@ func (n *NodeValidatableResource) validateResource(ctx EvalContext) tfdiags.Diag resp := provider.ValidateEphemeralResourceConfig(req) diags = diags.Append(resp.Diagnostics.InConfigBody(n.Config.Config, n.Addr.String())) case addrs.ListResourceMode: - schema := providerSchema.SchemaForResourceType(n.Config.Mode, n.Config.Type) - if schema.Body == nil { + schema := providerSchema.SchemaForListResourceType(n.Config.Type) + if schema.IsNil() { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Invalid list resource", @@ -482,7 +482,7 @@ func (n *NodeValidatableResource) validateResource(ctx EvalContext) tfdiags.Diag return diags } - blockVal, _, valDiags := ctx.EvaluateBlock(n.Config.Config, schema.Body, nil, keyData) + blockVal, _, valDiags := ctx.EvaluateBlock(n.Config.Config, schema.FullSchema, nil, keyData) diags = diags.Append(valDiags) if valDiags.HasErrors() { return diags @@ -502,6 +502,13 @@ func (n *NodeValidatableResource) validateResource(ctx EvalContext) tfdiags.Diag // Use unmarked value for validate request unmarkedBlockVal, _ := blockVal.UnmarkDeep() + + // if the config value is null, we still want to send a full object with all attributes being null + if !unmarkedBlockVal.IsNull() && unmarkedBlockVal.GetAttr("config").IsNull() { + mp := unmarkedBlockVal.AsValueMap() + mp["config"] = schema.ConfigSchema.EmptyValue() + unmarkedBlockVal = cty.ObjectVal(mp) + } req := providers.ValidateListResourceConfigRequest{ TypeName: n.Config.Type, Config: unmarkedBlockVal,