diff --git a/internal/command/query_test.go b/internal/command/query_test.go index c95b91c5c4f0..d79d1e13ef28 100644 --- a/internal/command/query_test.go +++ b/internal/command/query_test.go @@ -285,7 +285,9 @@ func queryFixtureProvider() *testing_provider.MockProvider { }, }, }, - Nesting: configschema.NestingSingle, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, }, }, } @@ -306,7 +308,9 @@ func queryFixtureProvider() *testing_provider.MockProvider { }, }, }, - Nesting: configschema.NestingSingle, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, }, }, } @@ -371,15 +375,13 @@ func queryFixtureProvider() *testing_provider.MockProvider { // Check the config to determine what kind of response to return wholeConfigMap := request.Config.AsValueMap() - configMap := wholeConfigMap["config"] - // For empty results test case - ami, ok := configMap.AsValueMap()["ami"] + ami, ok := wholeConfigMap["ami"] if ok && ami.AsString() == "ami-nonexistent" { return providers.ListResourceResponse{ Result: cty.ObjectVal(map[string]cty.Value{ "data": cty.ListValEmpty(cty.DynamicPseudoType), - "config": configMap, + "config": request.Config, }), } } @@ -410,7 +412,7 @@ func queryFixtureProvider() *testing_provider.MockProvider { "display_name": cty.StringVal("Test Instance 2"), }), }), - "config": configMap, + "config": request.Config, }), } case "test_database": @@ -428,14 +430,14 @@ func queryFixtureProvider() *testing_provider.MockProvider { "display_name": cty.StringVal("Test Database 1"), }), }), - "config": configMap, + "config": request.Config, }), } default: return providers.ListResourceResponse{ Result: cty.ObjectVal(map[string]cty.Value{ "data": cty.ListVal([]cty.Value{}), - "config": configMap, + "config": request.Config, }), } } diff --git a/internal/command/views/query_operation.go b/internal/command/views/query_operation.go index d9268328df8a..a99ba3d806e3 100644 --- a/internal/command/views/query_operation.go +++ b/internal/command/views/query_operation.go @@ -66,7 +66,7 @@ func (v *QueryOperationHuman) Plan(plan *plans.Plan, schemas *terraform.Schemas) for _, query := range plan.Changes.Queries { pSchema := schemas.ProviderSchema(query.ProviderAddr.Provider) addr := query.Addr - schema := pSchema.ListResourceTypes[addr.Resource.Resource.Type] + schema := pSchema.SchemaForListResourceType(addr.Resource.Resource.Type) results, err := query.Decode(schema) if err != nil { diff --git a/internal/configs/query_file.go b/internal/configs/query_file.go index 3fdd79823a5a..9c6ada846ad3 100644 --- a/internal/configs/query_file.go +++ b/internal/configs/query_file.go @@ -167,11 +167,10 @@ func decodeQueryListBlock(block *hcl.Block) (*Resource, hcl.Diagnostics) { }) diags = append(diags, contentDiags...) - var configBlock hcl.Body for _, block := range content.Blocks { switch block.Type { case "config": - if configBlock != nil { + if r.List.Config != nil { diags = diags.Append(&hcl.Diagnostic{ Severity: hcl.DiagError, Summary: "Duplicate config block", @@ -180,7 +179,7 @@ func decodeQueryListBlock(block *hcl.Block) (*Resource, hcl.Diagnostics) { }) continue } - configBlock = block.Body + r.List.Config = block.Body default: // Should not get here because the above should cover all // block types declared in the schema. diff --git a/internal/configs/resource.go b/internal/configs/resource.go index ae24fbaf8ae4..ab05b2b3075f 100644 --- a/internal/configs/resource.go +++ b/internal/configs/resource.go @@ -70,6 +70,9 @@ type ManagedResource struct { } type ListResource struct { + // Config is the "config" block from the list resource configuration. + Config hcl.Body + // By default, the results of a list resource only include the identities of // the discovered resources. If the user specifies "include_resources = true", // then the provider should include the resource data in the result. diff --git a/internal/plans/changes.go b/internal/plans/changes.go index ca362054b79d..4ad9e9089524 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -86,8 +86,8 @@ func (c *Changes) Encode(schemas *schemarepo.Schemas) (*ChangesSrc, error) { return changesSrc, fmt.Errorf("Changes.Encode: missing provider %s for %s", qi.ProviderAddr, qi.Addr) } - schema := p.ListResourceTypes[qi.Addr.Resource.Resource.Type] - if schema.Body == nil { + schema := p.SchemaForListResourceType(qi.Addr.Resource.Resource.Type) + if schema.IsNil() { return changesSrc, fmt.Errorf("Changes.Encode: missing schema for %s", qi.Addr) } rcs, err := qi.Encode(schema) @@ -308,8 +308,8 @@ func (qi *QueryInstance) DeepCopy() *QueryInstance { return &ret } -func (rc *QueryInstance) Encode(schema providers.Schema) (*QueryInstanceSrc, error) { - results, err := NewDynamicValue(rc.Results.Value, schema.Body.ImpliedType()) +func (rc *QueryInstance) Encode(schema providers.ListResourceSchema) (*QueryInstanceSrc, error) { + results, err := NewDynamicValue(rc.Results.Value, schema.FullSchema.ImpliedType()) if err != nil { return nil, err } diff --git a/internal/plans/changes_src.go b/internal/plans/changes_src.go index 426b062f43cc..0e4f6a6d0138 100644 --- a/internal/plans/changes_src.go +++ b/internal/plans/changes_src.go @@ -150,9 +150,9 @@ func (c *ChangesSrc) Decode(schemas *schemarepo.Schemas) (*Changes, error) { if !ok { return nil, fmt.Errorf("ChangesSrc.Decode: missing provider %s for %s", qis.ProviderAddr, qis.Addr) } - schema := p.ListResourceTypes[qis.Addr.Resource.Resource.Type] + schema := p.SchemaForListResourceType(qis.Addr.Resource.Resource.Type) - if schema.Body == nil { + if schema.IsNil() { return nil, fmt.Errorf("ChangesSrc.Decode: missing schema for %s", qis.Addr) } @@ -210,8 +210,8 @@ type QueryInstanceSrc struct { Generated genconfig.ImportGroup } -func (qis *QueryInstanceSrc) Decode(schema providers.Schema) (*QueryInstance, error) { - query, err := qis.Results.Decode(schema.Body.ImpliedType()) +func (qis *QueryInstanceSrc) Decode(schema providers.ListResourceSchema) (*QueryInstance, error) { + query, err := qis.Results.Decode(schema.FullSchema.ImpliedType()) if err != nil { return nil, err } 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..f5e7edf05e6c 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 { @@ -374,18 +356,13 @@ func (p *GRPCProvider) ValidateListResourceConfig(r providers.ValidateListResour return resp } - listResourceSchema, ok := schema.ListResourceTypes[r.TypeName] - if !ok { + listResourceSchema := schema.SchemaForListResourceType(r.TypeName) + if listResourceSchema.IsNil() { 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") - } - mp, err := msgpack.Marshal(config, configSchema.ImpliedType()) + mp, err := msgpack.Marshal(r.Config, listResourceSchema.ConfigSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -1329,24 +1306,19 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L return resp } - listResourceSchema, ok := schema.ListResourceTypes[r.TypeName] - if !ok { - resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("unknown list resource type %q", r.TypeName)) - return resp - } - resourceSchema, ok := schema.ResourceTypes[r.TypeName] if !ok || resourceSchema.Identity == nil { resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("identity schema not found for resource type %s", 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") + listResourceSchema := schema.SchemaForListResourceType(r.TypeName) + if listResourceSchema.IsNil() { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("unknown list resource type %q", r.TypeName)) + return resp } - mp, err := msgpack.Marshal(config, configSchema.ImpliedType()) + + mp, err := msgpack.Marshal(r.Config, listResourceSchema.ConfigSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -1445,7 +1417,7 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L // and the elements of the result of a list resource instance (list.aws_instance.test.data[index]) resp.Result = cty.ObjectVal(map[string]cty.Value{ "data": cty.TupleVal(results), - "config": config, + "config": r.Config, }) return resp } diff --git a/internal/plugin/grpc_provider_test.go b/internal/plugin/grpc_provider_test.go index 657b8b7f33d3..0e6319acb72d 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]any{"filter_attr": "value", "nested_filter": map[string]any{"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,7 +529,8 @@ 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 := converted.Body.BlockTypes["config"].Block.EmptyValue() resp := p.ValidateListResourceConfig(providers.ValidateListResourceConfigRequest{ TypeName: "list", Config: cfg, @@ -1438,8 +1466,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 +1530,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 +1546,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{ @@ -1647,8 +1698,9 @@ func TestGRPCProvider_ListResource(t *testing.T) { // Create the request configVal := cty.ObjectVal(map[string]cty.Value{ - "config": cty.ObjectVal(map[string]cty.Value{ - "filter_attr": cty.StringVal("filter-value"), + "filter_attr": cty.StringVal("filter-value"), + "nested_filter": cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), }), }) request := providers.ListResourceRequest{ @@ -1729,8 +1781,9 @@ func TestGRPCProvider_ListResource_Error(t *testing.T) { ).Return(nil, fmt.Errorf("provider error")) configVal := cty.ObjectVal(map[string]cty.Value{ - "config": cty.ObjectVal(map[string]cty.Value{ - "filter_attr": cty.StringVal("filter-value"), + "filter_attr": cty.StringVal("filter-value"), + "nested_filter": cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), }), }) request := providers.ListResourceRequest{ @@ -1744,8 +1797,9 @@ func TestGRPCProvider_ListResource_Error(t *testing.T) { 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"), + "filter_attr": cty.StringVal("filter-value"), + "nested_filter": cty.ObjectVal(map[string]cty.Value{ + "nested_attr": cty.StringVal("value"), }), }) request := providers.ListResourceRequest{ @@ -2007,8 +2061,9 @@ func TestGRPCProvider_ListResource_Limit(t *testing.T) { // Create the request configVal := cty.ObjectVal(map[string]cty.Value{ - "config": cty.ObjectVal(map[string]cty.Value{ - "filter_attr": cty.StringVal("filter-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..405a2c9685fd 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 { @@ -372,17 +354,13 @@ func (p *GRPCProvider) ValidateListResourceConfig(r providers.ValidateListResour return resp } - listResourceSchema, ok := schema.ListResourceTypes[r.TypeName] - if !ok { + listResourceSchema := schema.SchemaForListResourceType(r.TypeName) + if listResourceSchema.IsNil() { 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") - } - mp, err := msgpack.Marshal(config, configSchema.ImpliedType()) + + mp, err := msgpack.Marshal(r.Config, listResourceSchema.ConfigSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -1324,24 +1302,19 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L return resp } - listResourceSchema, ok := schema.ListResourceTypes[r.TypeName] - if !ok { - resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("unknown list resource type %q", r.TypeName)) - return resp - } - resourceSchema, ok := schema.ResourceTypes[r.TypeName] if !ok || resourceSchema.Identity == nil { resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("identity schema not found for resource type %s", 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") + listResourceSchema := schema.SchemaForListResourceType(r.TypeName) + if listResourceSchema.IsNil() { + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("unknown list resource type %q", r.TypeName)) + return resp } - mp, err := msgpack.Marshal(config, configSchema.ImpliedType()) + + mp, err := msgpack.Marshal(r.Config, listResourceSchema.ConfigSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -1440,7 +1413,7 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L // and the elements of the result of a list resource instance (list.aws_instance.test.data[index]) resp.Result = cty.ObjectVal(map[string]cty.Value{ "data": cty.TupleVal(results), - "config": config, + "config": r.Config, }) return resp } diff --git a/internal/plugin6/grpc_provider_test.go b/internal/plugin6/grpc_provider_test.go index 5e375c66ce53..01b1240e1bca 100644 --- a/internal/plugin6/grpc_provider_test.go +++ b/internal/plugin6/grpc_provider_test.go @@ -486,7 +486,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{}{"filter_attr": "value"}) resp := p.ValidateListResourceConfig(providers.ValidateListResourceConfigRequest{ TypeName: "list", Config: cfg, @@ -522,7 +522,8 @@ 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 := converted.Body.BlockTypes["config"].Block.EmptyValue() resp := p.ValidateListResourceConfig(providers.ValidateListResourceConfigRequest{ TypeName: "list", Config: cfg, @@ -1458,7 +1459,9 @@ func TestGRPCProvider_GetSchema_ListResourceTypes(t *testing.T) { }, }, }, - Nesting: configschema.NestingSingle, + Nesting: configschema.NestingSingle, + MinItems: 1, + MaxItems: 1, }, }, }, @@ -1600,9 +1603,7 @@ func TestGRPCProvider_ListResource(t *testing.T) { // Create the request configVal := cty.ObjectVal(map[string]cty.Value{ - "config": cty.ObjectVal(map[string]cty.Value{ - "filter_attr": cty.StringVal("filter-value"), - }), + "filter_attr": cty.StringVal("filter-value"), }) request := providers.ListResourceRequest{ TypeName: "list", @@ -1682,9 +1683,7 @@ func TestGRPCProvider_ListResource_Error(t *testing.T) { ).Return(nil, fmt.Errorf("provider error")) configVal := cty.ObjectVal(map[string]cty.Value{ - "config": cty.ObjectVal(map[string]cty.Value{ - "filter_attr": cty.StringVal("filter-value"), - }), + "filter_attr": cty.StringVal("filter-value"), }) request := providers.ListResourceRequest{ TypeName: "list", @@ -1697,9 +1696,7 @@ func TestGRPCProvider_ListResource_Error(t *testing.T) { 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"), - }), + "filter_attr": cty.StringVal("filter-value"), }) request := providers.ListResourceRequest{ TypeName: "list", @@ -1960,9 +1957,7 @@ func TestGRPCProvider_ListResource_Limit(t *testing.T) { // Create the request configVal := cty.ObjectVal(map[string]cty.Value{ - "config": cty.ObjectVal(map[string]cty.Value{ - "filter_attr": cty.StringVal("filter-value"), - }), + "filter_attr": cty.StringVal("filter-value"), }) request := providers.ListResourceRequest{ TypeName: "list", 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/providers/testing/provider_mock.go b/internal/providers/testing/provider_mock.go index 82e4dd5db08e..9fe120d592c2 100644 --- a/internal/providers/testing/provider_mock.go +++ b/internal/providers/testing/provider_mock.go @@ -340,12 +340,12 @@ func (p *MockProvider) ValidateListResourceConfig(r providers.ValidateListResour p.ValidateListResourceConfigRequest = r // Marshall the value to replicate behavior by the GRPC protocol - listSchema, ok := p.getProviderSchema().ListResourceTypes[r.TypeName] - if !ok { + listSchema := p.getProviderSchema().SchemaForListResourceType(r.TypeName) + if listSchema.IsNil() { resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("no schema found for %q", r.TypeName)) return resp } - _, err := msgpack.Marshal(r.Config, listSchema.Body.ImpliedType()) + _, err := msgpack.Marshal(r.Config, listSchema.ConfigSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go index 3f95027dcd26..425be1367f83 100644 --- a/internal/terraform/context_plan_query_test.go +++ b/internal/terraform/context_plan_query_test.go @@ -5,13 +5,13 @@ package terraform import ( "fmt" - "maps" "sort" "strings" "testing" "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 +24,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", @@ -99,12 +100,10 @@ func TestContext2Plan_queryList(t *testing.T) { resp = append(resp, cty.ObjectVal(mp)) } - ret := request.Config.AsValueMap() - maps.Copy(ret, map[string]cty.Value{ - "data": cty.TupleVal(resp), - }) - - return providers.ListResourceResponse{Result: cty.ObjectVal(ret)} + return providers.ListResourceResponse{Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.TupleVal(resp), + "config": request.Config, + })} }, assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { expectedResources := []string{"list.test_resource.test", "list.test_resource.test2"} @@ -112,7 +111,7 @@ func TestContext2Plan_queryList(t *testing.T) { generatedCfgs := make([]string, 0) for _, change := range changes.Queries { actualResources = append(actualResources, change.Addr.String()) - schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] + schema := sch.SchemaForListResourceType(change.Addr.Resource.Resource.Type) cs, err := change.Decode(schema) if err != nil { t.Fatalf("failed to decode change: %s", err) @@ -208,23 +207,191 @@ func TestContext2Plan_queryList(t *testing.T) { })) } - ret := map[string]cty.Value{ - "data": cty.TupleVal(resp), + return providers.ListResourceResponse{Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.TupleVal(resp), + "config": request.Config, + })} + }, + assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { + expectedResources := []string{"list.test_resource.test[0]", "list.test_resource.test2"} + actualResources := make([]string, 0) + for _, change := range changes.Queries { + actualResources = append(actualResources, change.Addr.String()) + schema := sch.SchemaForListResourceType(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) + } } - for k, v := range request.Config.AsValueMap() { - if k != "data" { - ret[k] = v + 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: "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, + }, + } - return providers.ListResourceResponse{Result: cty.ObjectVal(ret)} + }, + 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)), + })) + } + + return providers.ListResourceResponse{Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.TupleVal(resp), + "config": request.Config, + })} }, assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { - expectedResources := []string{"list.test_resource.test[0]", "list.test_resource.test2"} + 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] + schema := sch.SchemaForListResourceType(change.Addr.Resource.Resource.Type) cs, err := change.Decode(schema) if err != nil { t.Fatalf("failed to decode change: %s", err) @@ -301,10 +468,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 +506,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 +556,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{ @@ -397,16 +589,10 @@ func TestContext2Plan_queryList(t *testing.T) { })) } - 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)} + return providers.ListResourceResponse{Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.TupleVal(resp), + "config": request.Config, + })} }, }, { @@ -444,9 +630,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()) + } }, }, { @@ -510,23 +701,17 @@ func TestContext2Plan_queryList(t *testing.T) { })) } - 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)} + return providers.ListResourceResponse{Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.TupleVal(resp), + "config": request.Config, + })} }, assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { expectedResources := []string{"list.test_resource.test1", "list.test_resource.test2"} actualResources := make([]string, 0) for _, change := range changes.Queries { actualResources = append(actualResources, change.Addr.String()) - schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] + schema := sch.SchemaForListResourceType(change.Addr.Resource.Resource.Type) cs, err := change.Decode(schema) if err != nil { t.Fatalf("failed to decode change: %s", err) @@ -564,8 +749,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 { @@ -621,23 +805,17 @@ func TestContext2Plan_queryList(t *testing.T) { })) } - 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)} + return providers.ListResourceResponse{Result: cty.ObjectVal(map[string]cty.Value{ + "data": cty.TupleVal(resp), + "config": request.Config, + })} }, assertChanges: func(sch providers.ProviderSchema, changes *plans.ChangesSrc) { expectedResources := []string{"list.test_resource.test1[\"foo\"]", "list.test_resource.test1[\"bar\"]", "list.test_resource.test2[\"foo\"]", "list.test_resource.test2[\"bar\"]"} actualResources := make([]string, 0) for _, change := range changes.Queries { actualResources = append(actualResources, change.Addr.String()) - schema := sch.ListResourceTypes[change.Addr.Resource.Resource.Type] + schema := sch.SchemaForListResourceType(change.Addr.Resource.Resource.Type) cs, err := change.Decode(schema) if err != nil { t.Fatalf("failed to decode change: %s", err) @@ -688,8 +866,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() { + 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 +889,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 +912,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 +1018,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() { + 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..b3ec09a5ceef 100644 --- a/internal/terraform/node_resource_plan_instance_query.go +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -36,9 +36,16 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di keyData = EvalDataForInstanceKey(addr.Resource.Key, forEach) } - // evaluate the list config block + 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 entire list block. + // We don't single out the config block here so that we can get diagnostics with source information if the config block is invalid. 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 @@ -64,12 +71,19 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di return diags } + // extract the config value from the unmarked block value + // if the config value is null, we still want to send a full object which has all its attributes as empty values. + configVal := unmarkedBlockVal.GetAttr("config") + if configVal.IsNull() { + configVal = schema.ConfigSchema.EmptyValue() + } + rId := HookResourceIdentity{ Addr: addr, ProviderAddr: n.ResolvedProvider.Provider, } ctx.Hook(func(h Hook) (HookAction, error) { - return h.PreListQuery(rId, unmarkedBlockVal.GetAttr("config")) + return h.PreListQuery(rId, configVal) }) // if we are generating config, we implicitly set include_resource to true @@ -79,10 +93,11 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di } log.Printf("[TRACE] NodePlannableResourceInstance: Re-validating config for %s", n.Addr) + validateResp := provider.ValidateListResourceConfig( providers.ValidateListResourceConfigRequest{ TypeName: n.Config.Type, - Config: unmarkedBlockVal, + Config: configVal, IncludeResourceObject: includeRscCty, Limit: limitCty, }, @@ -96,7 +111,7 @@ func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (di // to actually call the provider to list the data. resp := provider.ListResource(providers.ListResourceRequest{ TypeName: n.Config.Type, - Config: unmarkedBlockVal, + Config: configVal, Limit: limit, IncludeResourceObject: includeRsc, }) diff --git a/internal/terraform/node_resource_validate.go b/internal/terraform/node_resource_validate.go index 8d106bc16856..9657fae7887a 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,9 +502,17 @@ func (n *NodeValidatableResource) validateResource(ctx EvalContext) tfdiags.Diag // Use unmarked value for validate request unmarkedBlockVal, _ := blockVal.UnmarkDeep() + + // extract the config value from the unmarked block value + // if the config value is null, we still want to send a full object which has all its attributes as empty values. + configVal := unmarkedBlockVal.GetAttr("config") + if configVal.IsNull() { + configVal = schema.ConfigSchema.EmptyValue() + } + req := providers.ValidateListResourceConfigRequest{ TypeName: n.Config.Type, - Config: unmarkedBlockVal, + Config: configVal, IncludeResourceObject: includeResource, Limit: limit, }