diff --git a/internal/addrs/parse_target.go b/internal/addrs/parse_target.go index 5e59be6ec9..bc3120b2ae 100644 --- a/internal/addrs/parse_target.go +++ b/internal/addrs/parse_target.go @@ -177,6 +177,9 @@ func parseResourceInstanceUnderModule(moduleAddr ModuleInstance, allowPartial bo case "ephemeral": mode = EphemeralResourceMode remain = remain[1:] + case "list": + mode = ListResourceMode + remain = remain[1:] case "resource": // Starting a resource address with "resource" is optional, so we'll // just ignore it. diff --git a/internal/configs/configschema/internal_validate.go b/internal/configs/configschema/internal_validate.go index d2343c74b0..7da52b23e7 100644 --- a/internal/configs/configschema/internal_validate.go +++ b/internal/configs/configschema/internal_validate.go @@ -175,6 +175,10 @@ func (a *Attribute) internalValidate(name, prefix string) error { func (o *Object) InternalValidate() error { var err error + if o.Nesting == nestingModeInvalid { + return fmt.Errorf("object schema nesting mode is invalid") + } + for name, attrS := range o.Attributes { if attrS == nil { err = errors.Join(err, fmt.Errorf("%s: attribute schema is nil", name)) diff --git a/internal/configs/configschema/internal_validate_test.go b/internal/configs/configschema/internal_validate_test.go index f0be0cebc1..e7828c61a1 100644 --- a/internal/configs/configschema/internal_validate_test.go +++ b/internal/configs/configschema/internal_validate_test.go @@ -333,7 +333,7 @@ func TestObjectInternalValidate(t *testing.T) { Errs []string }{ "empty": { - &Object{}, + &Object{Nesting: NestingSingle}, []string{}, }, "valid": { diff --git a/internal/plans/changes.go b/internal/plans/changes.go index c3a389ac69..88b20f151b 100644 --- a/internal/plans/changes.go +++ b/internal/plans/changes.go @@ -56,6 +56,8 @@ func (c *Changes) Encode(schemas *schemarepo.Schemas) (*ChangesSrc, error) { schema = p.ResourceTypes[rc.Addr.Resource.Resource.Type] case addrs.DataResourceMode: schema = p.DataSources[rc.Addr.Resource.Resource.Type] + case addrs.ListResourceMode: + schema = p.ListResourceTypes[rc.Addr.Resource.Resource.Type] default: panic(fmt.Sprintf("unexpected resource mode %s", rc.Addr.Resource.Resource.Mode)) } diff --git a/internal/plans/changes_src.go b/internal/plans/changes_src.go index 9af56cf454..8482cbe32c 100644 --- a/internal/plans/changes_src.go +++ b/internal/plans/changes_src.go @@ -114,6 +114,8 @@ func (c *ChangesSrc) Decode(schemas *schemarepo.Schemas) (*Changes, error) { schema = p.ResourceTypes[rcs.Addr.Resource.Resource.Type] case addrs.DataResourceMode: schema = p.DataSources[rcs.Addr.Resource.Resource.Type] + case addrs.ListResourceMode: + schema = p.ListResourceTypes[rcs.Addr.Resource.Resource.Type] default: panic(fmt.Sprintf("unexpected resource mode %s", rcs.Addr.Resource.Resource.Mode)) } diff --git a/internal/plugin/grpc_provider.go b/internal/plugin/grpc_provider.go index 638734614f..bd1010429b 100644 --- a/internal/plugin/grpc_provider.go +++ b/internal/plugin/grpc_provider.go @@ -21,6 +21,7 @@ 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" @@ -170,7 +171,24 @@ func (p *GRPCProvider) GetProviderSchema() providers.GetProviderSchemaResponse { } for name, list := range protoResp.ListResourceSchemas { - resp.ListResourceTypes[name] = convert.ProtoToProviderSchema(list, nil) + 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, + }, + }, + }, + } } if decls, err := convert.FunctionDeclsFromProto(protoResp.Functions); err == nil { @@ -357,7 +375,8 @@ func (p *GRPCProvider) ValidateListResourceConfig(r providers.ValidateListResour return resp } - mp, err := msgpack.Marshal(r.Config, listResourceSchema.Body.ImpliedType()) + configSchema := listResourceSchema.Body.BlockTypes["config"] + mp, err := msgpack.Marshal(r.Config, configSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -1274,7 +1293,8 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L return resp } - mp, err := msgpack.Marshal(r.Config, listResourceSchema.Body.ImpliedType()) + configSchema := listResourceSchema.Body.BlockTypes["config"] + mp, err := msgpack.Marshal(r.Config, configSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -1298,43 +1318,42 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L return resp } - var results []providers.ListResourceEvent + resp.Result = cty.DynamicVal + results := make([]cty.Value, 0) // Process the stream for { if int64(len(results)) >= r.Limit { // If we have reached the limit, we stop receiving events - resp.Results = results - return resp + break } event, err := client.Recv() if err == io.EOF { // End of stream, we're done - resp.Results = results - return resp + break } if err != nil { - resp.Results = results resp.Diagnostics = resp.Diagnostics.Append(err) - return resp + break } - // Process the event - resourceEvent := providers.ListResourceEvent{ - DisplayName: event.DisplayName, - Diagnostics: convert.ProtoToDiagnostics(event.Diagnostic), + obj := map[string]cty.Value{ + "display_name": cty.StringVal(event.DisplayName), + "state": cty.NullVal(resourceSchema.Body.ImpliedType()), + "identity": cty.NullVal(resourceSchema.Identity.ImpliedType()), } + resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(event.Diagnostic)) // Handle identity data - it must be present if event.Identity == nil || event.Identity.IdentityData == nil { - resourceEvent.Diagnostics = resourceEvent.Diagnostics.Append(fmt.Errorf("missing identity data in ListResource event for %s", r.TypeName)) + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("missing identity data in ListResource event for %s", r.TypeName)) } else { identityVal, err := decodeDynamicValue(event.Identity.IdentityData, resourceSchema.Identity.ImpliedType()) if err != nil { - resourceEvent.Diagnostics = resourceEvent.Diagnostics.Append(err) + resp.Diagnostics = resp.Diagnostics.Append(err) } else { - resourceEvent.Identity = identityVal + obj["identity"] = identityVal } } @@ -1343,14 +1362,26 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L // Use the ResourceTypes schema for the resource object resourceObj, err := decodeDynamicValue(event.ResourceObject, resourceSchema.Body.ImpliedType()) if err != nil { - resourceEvent.Diagnostics = resourceEvent.Diagnostics.Append(err) + resp.Diagnostics = resp.Diagnostics.Append(err) } else { - resourceEvent.ResourceObject = resourceObj + obj["state"] = resourceObj } } - results = append(results, resourceEvent) + if resp.Diagnostics.HasErrors() { + break + } + + results = append(results, cty.ObjectVal(obj)) + } + + // The provider result of a list resource is always a list, but + // we will wrap that list in an object with a single attribute "data", + // so that we can differentiate between a list resource instance (list.aws_instance.test[index]) + // 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)}) + return resp } func (p *GRPCProvider) ValidateStateStoreConfig(r providers.ValidateStateStoreConfigRequest) providers.ValidateStateStoreConfigResponse { diff --git a/internal/plugin/grpc_provider_test.go b/internal/plugin/grpc_provider_test.go index 461d0e54cd..74477609e7 100644 --- a/internal/plugin/grpc_provider_test.go +++ b/internal/plugin/grpc_provider_test.go @@ -13,8 +13,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/configs/hcl2shim" + "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/schemarepo" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" "go.uber.org/mock/gomock" @@ -22,6 +25,7 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/hashicorp/terraform/internal/plugin/convert" mockproto "github.com/hashicorp/terraform/internal/plugin/mock_proto" proto "github.com/hashicorp/terraform/internal/tfplugin5" ) @@ -1356,6 +1360,120 @@ func TestGRPCProvider_closeEphemeralResource(t *testing.T) { checkDiags(t, resp.Diagnostics) } +func TestGRPCProvider_GetSchema_ListResourceTypes(t *testing.T) { + p := &GRPCProvider{ + client: mockProviderClient(t), + ctx: context.Background(), + } + + resp := p.GetProviderSchema() + listResourceSchema := resp.ListResourceTypes + expected := map[string]providers.Schema{ + "list": { + Version: 1, + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "data": { + Type: cty.DynamicPseudoType, + Computed: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "config": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "filter_attr": { + Type: cty.String, + Required: true, + }, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + }, + }, + } + checkDiags(t, resp.Diagnostics) + + actualBody := convert.ConfigSchemaToProto(listResourceSchema["list"].Body).String() + expectedBody := convert.ConfigSchemaToProto(expected["list"].Body).String() + if actualBody != expectedBody { + t.Fatalf("expected %v, got %v", expectedBody, actualBody) + } +} + +func TestGRPCProvider_Encode(t *testing.T) { + // TODO: This is the only test in this package that imports plans. If that + // ever leads to a circular import, we should consider moving this test to + // a different package or refactoring the test to not use plans. + p := &GRPCProvider{ + client: mockProviderClient(t), + ctx: context.Background(), + Addr: addrs.ImpliedProviderForUnqualifiedType("testencode"), + } + resp := p.GetProviderSchema() + + src := plans.NewChanges() + src.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChange{ + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ListResourceMode, + Type: "list", + Name: "test", + }, + Key: addrs.NoKey, + }, + }, + ProviderAddr: addrs.AbsProviderConfig{ + Provider: p.Addr, + }, + Change: plans.Change{ + Before: cty.NullVal(cty.Object(map[string]cty.Type{ + "config": cty.Object(map[string]cty.Type{ + "filter_attr": cty.String, + }), + "data": cty.List(cty.Object(map[string]cty.Type{ + "state": cty.Object(map[string]cty.Type{ + "resource_attr": cty.String, + }), + "identity": cty.Object(map[string]cty.Type{ + "id_attr": cty.String, + }), + })), + })), + After: cty.ObjectVal(map[string]cty.Value{ + "config": cty.ObjectVal(map[string]cty.Value{ + "filter_attr": cty.StringVal("value"), + }), + "data": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "state": cty.ObjectVal(map[string]cty.Value{ + "resource_attr": cty.StringVal("value"), + }), + "identity": cty.ObjectVal(map[string]cty.Value{ + "id_attr": cty.StringVal("value"), + }), + }), + }), + }), + }, + }) + _, err := src.Encode(&schemarepo.Schemas{ + Providers: map[addrs.Provider]providers.ProviderSchema{ + p.Addr: { + ResourceTypes: resp.ResourceTypes, + ListResourceTypes: resp.ListResourceTypes, + }, + }, + }) + if err != nil { + t.Fatalf("unexpected error encoding changes: %s", err) + } +} + // Mock implementation of the ListResource stream client type mockListResourceStreamClient struct { events []*proto.ListResource_Event @@ -1424,47 +1542,57 @@ func TestGRPCProvider_ListResource(t *testing.T) { resp := p.ListResource(request) checkDiags(t, resp.Diagnostics) - results := resp.Results - - // Verify that we received both events - if len(results) != 2 { - t.Fatalf("Expected 2 events, got %d", len(results)) + data := resp.Result.AsValueMap() + if _, ok := data["data"]; !ok { + t.Fatal("Expected 'data' key in result") } + // Verify that we received both events + if len(data["data"].AsValueSlice()) != 2 { + t.Fatalf("Expected 2 resources, got %d", len(data["data"].AsValueSlice())) + } + results := data["data"].AsValueSlice() // Verify first event - if results[0].DisplayName != "Test Resource 1" { - t.Errorf("Expected DisplayName 'Test Resource 1', got '%s'", results[0].DisplayName) + displayName := results[0].GetAttr("display_name") + if displayName.AsString() != "Test Resource 1" { + t.Errorf("Expected DisplayName 'Test Resource 1', got '%s'", displayName.AsString()) } expectedId1 := cty.ObjectVal(map[string]cty.Value{ "id_attr": cty.StringVal("id-1"), }) - if !results[0].Identity.RawEquals(expectedId1) { - t.Errorf("Expected Identity %#v, got %#v", expectedId1, results[0].Identity) + + identity := results[0].GetAttr("identity") + if !identity.RawEquals(expectedId1) { + t.Errorf("Expected Identity %#v, got %#v", expectedId1, identity) } // ResourceObject should be null for the first event as it wasn't provided - if !results[0].ResourceObject.IsNull() { - t.Errorf("Expected ResourceObject to be null, got %#v", results[0].ResourceObject) + resourceObject := results[0].GetAttr("state") + if !resourceObject.IsNull() { + t.Errorf("Expected ResourceObject to be null, got %#v", resourceObject) } // Verify second event - if results[1].DisplayName != "Test Resource 2" { - t.Errorf("Expected DisplayName 'Test Resource 2', got '%s'", results[1].DisplayName) + displayName = results[1].GetAttr("display_name") + if displayName.AsString() != "Test Resource 2" { + t.Errorf("Expected DisplayName 'Test Resource 2', got '%s'", displayName.AsString()) } expectedId2 := cty.ObjectVal(map[string]cty.Value{ "id_attr": cty.StringVal("id-2"), }) - if !results[1].Identity.RawEquals(expectedId2) { - t.Errorf("Expected Identity %#v, got %#v", expectedId2, results[1].Identity) + identity = results[1].GetAttr("identity") + if !identity.RawEquals(expectedId2) { + t.Errorf("Expected Identity %#v, got %#v", expectedId2, identity) } expectedResource := cty.ObjectVal(map[string]cty.Value{ "resource_attr": cty.StringVal("value"), }) - if !results[1].ResourceObject.RawEquals(expectedResource) { - t.Errorf("Expected ResourceObject %#v, got %#v", expectedResource, results[1].ResourceObject) + resourceObject = results[1].GetAttr("state") + if !resourceObject.RawEquals(expectedResource) { + t.Errorf("Expected ResourceObject %#v, got %#v", expectedResource, resourceObject) } } @@ -1539,12 +1667,12 @@ func TestGRPCProvider_ListResource_Diagnostics(t *testing.T) { resp := p.ListResource(request) checkDiags(t, resp.Diagnostics) - // Verify that we received one event with diagnostics - if len(resp.Results) != 1 { - t.Fatalf("Expected 1 event, got %d", len(resp.Results)) + data := resp.Result.AsValueMap() + if _, ok := data["data"]; !ok { + t.Fatal("Expected 'data' key in result") } - if !resp.Results[0].Diagnostics.HasWarnings() { + if !resp.Diagnostics.HasWarnings() { t.Fatal("Expected warning diagnostics, but got none") } } @@ -1591,6 +1719,7 @@ func TestGRPCProvider_ListResource_Limit(t *testing.T) { gomock.Any(), ).Return(mockStream, nil) + // Create the request configVal := cty.ObjectVal(map[string]cty.Value{ "filter_attr": cty.StringVal("filter-value"), }) @@ -1603,8 +1732,17 @@ func TestGRPCProvider_ListResource_Limit(t *testing.T) { resp := p.ListResource(request) checkDiags(t, resp.Diagnostics) - results := resp.Results + data := resp.Result.AsValueMap() + if _, ok := data["data"]; !ok { + t.Fatal("Expected 'data' key in result") + } + // Verify that we received both events + if len(data["data"].AsValueSlice()) != 2 { + t.Fatalf("Expected 2 resources, got %d", len(data["data"].AsValueSlice())) + } + results := data["data"].AsValueSlice() + // Verify that we received both events if len(results) != 2 { t.Fatalf("Expected 2 events, got %d", len(results)) } diff --git a/internal/plugin6/grpc_provider.go b/internal/plugin6/grpc_provider.go index 69cc101792..21b63699a3 100644 --- a/internal/plugin6/grpc_provider.go +++ b/internal/plugin6/grpc_provider.go @@ -21,6 +21,7 @@ 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" @@ -171,7 +172,24 @@ func (p *GRPCProvider) GetProviderSchema() providers.GetProviderSchemaResponse { } for name, list := range protoResp.ListResourceSchemas { - resp.ListResourceTypes[name] = convert.ProtoToProviderSchema(list, nil) + 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, + }, + }, + }, + } } for name, store := range protoResp.StateStoreSchemas { @@ -354,8 +372,8 @@ func (p *GRPCProvider) ValidateListResourceConfig(r providers.ValidateListResour resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("unknown list resource type %q", r.TypeName)) return resp } - - mp, err := msgpack.Marshal(r.Config, listResourceSchema.Body.ImpliedType()) + configSchema := listResourceSchema.Body.BlockTypes["config"] + mp, err := msgpack.Marshal(r.Config, configSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -1270,7 +1288,8 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L return resp } - mp, err := msgpack.Marshal(r.Config, listResourceSchema.Body.ImpliedType()) + configSchema := listResourceSchema.Body.BlockTypes["config"] + mp, err := msgpack.Marshal(r.Config, configSchema.ImpliedType()) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp @@ -1283,50 +1302,53 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L Limit: r.Limit, } - // Start the streaming RPC - client, err := p.client.ListResource(p.ctx, protoReq) + // Start the streaming RPC with a context. The context will be cancelled + // when this function returns, which will stop the stream if it is still + // running. + ctx, cancel := context.WithCancel(p.ctx) + defer cancel() + client, err := p.client.ListResource(ctx, protoReq) if err != nil { resp.Diagnostics = resp.Diagnostics.Append(err) return resp } - var results []providers.ListResourceEvent + resp.Result = cty.DynamicVal + results := make([]cty.Value, 0) // Process the stream for { if int64(len(results)) >= r.Limit { // If we have reached the limit, we stop receiving events - resp.Results = results - return resp + break } event, err := client.Recv() if err == io.EOF { // End of stream, we're done - resp.Results = results - return resp + break } if err != nil { - resp.Results = results resp.Diagnostics = resp.Diagnostics.Append(err) - return resp + break } - // Process the event - resourceEvent := providers.ListResourceEvent{ - DisplayName: event.DisplayName, - Diagnostics: convert.ProtoToDiagnostics(event.Diagnostic), + obj := map[string]cty.Value{ + "display_name": cty.StringVal(event.DisplayName), + "state": cty.NullVal(resourceSchema.Body.ImpliedType()), + "identity": cty.NullVal(resourceSchema.Identity.ImpliedType()), } + resp.Diagnostics = resp.Diagnostics.Append(convert.ProtoToDiagnostics(event.Diagnostic)) // Handle identity data - it must be present if event.Identity == nil || event.Identity.IdentityData == nil { - resourceEvent.Diagnostics = resourceEvent.Diagnostics.Append(fmt.Errorf("missing identity data in ListResource event for %s", r.TypeName)) + resp.Diagnostics = resp.Diagnostics.Append(fmt.Errorf("missing identity data in ListResource event for %s", r.TypeName)) } else { identityVal, err := decodeDynamicValue(event.Identity.IdentityData, resourceSchema.Identity.ImpliedType()) if err != nil { - resourceEvent.Diagnostics = resourceEvent.Diagnostics.Append(err) + resp.Diagnostics = resp.Diagnostics.Append(err) } else { - resourceEvent.Identity = identityVal + obj["identity"] = identityVal } } @@ -1335,14 +1357,26 @@ func (p *GRPCProvider) ListResource(r providers.ListResourceRequest) providers.L // Use the ResourceTypes schema for the resource object resourceObj, err := decodeDynamicValue(event.ResourceObject, resourceSchema.Body.ImpliedType()) if err != nil { - resourceEvent.Diagnostics = resourceEvent.Diagnostics.Append(err) + resp.Diagnostics = resp.Diagnostics.Append(err) } else { - resourceEvent.ResourceObject = resourceObj + obj["state"] = resourceObj } } - results = append(results, resourceEvent) + if resp.Diagnostics.HasErrors() { + break + } + + results = append(results, cty.ObjectVal(obj)) + } + + // The provider result of a list resource is always a list, but + // we will wrap that list in an object with a single attribute "data", + // so that we can differentiate between a list resource instance (list.aws_instance.test[index]) + // 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)}) + return resp } func (p *GRPCProvider) ValidateStateStoreConfig(r providers.ValidateStateStoreConfigRequest) providers.ValidateStateStoreConfigResponse { diff --git a/internal/plugin6/grpc_provider_test.go b/internal/plugin6/grpc_provider_test.go index d3264b0fa6..fe34b59515 100644 --- a/internal/plugin6/grpc_provider_test.go +++ b/internal/plugin6/grpc_provider_test.go @@ -14,8 +14,11 @@ import ( "github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp/cmpopts" "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configschema" "github.com/hashicorp/terraform/internal/configs/hcl2shim" + "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/schemarepo" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/zclconf/go-cty/cty" "go.uber.org/mock/gomock" @@ -23,6 +26,7 @@ import ( "google.golang.org/grpc/status" "google.golang.org/protobuf/types/known/timestamppb" + "github.com/hashicorp/terraform/internal/plugin6/convert" mockproto "github.com/hashicorp/terraform/internal/plugin6/mock_proto" proto "github.com/hashicorp/terraform/internal/tfplugin6" ) @@ -1362,6 +1366,119 @@ func TestGRPCProvider_closeEphemeralResource(t *testing.T) { checkDiags(t, resp.Diagnostics) } +func TestGRPCProvider_GetSchema_ListResourceTypes(t *testing.T) { + p := &GRPCProvider{ + client: mockProviderClient(t), + ctx: context.Background(), + } + + resp := p.GetProviderSchema() + listResourceSchema := resp.ListResourceTypes + expected := map[string]providers.Schema{ + "list": { + Version: 1, + Body: &configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "data": { + Type: cty.DynamicPseudoType, + Computed: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "config": { + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "filter_attr": { + Type: cty.String, + Required: true, + }, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + }, + }, + } + checkDiags(t, resp.Diagnostics) + + actualBody := convert.ConfigSchemaToProto(listResourceSchema["list"].Body).String() + expectedBody := convert.ConfigSchemaToProto(expected["list"].Body).String() + if diff := cmp.Diff(expectedBody, actualBody); diff != "" { + t.Fatalf("unexpected body (-want +got):\n%s", diff) + } +} + +func TestGRPCProvider_Encode(t *testing.T) { + // TODO: This is the only test in this package that imports plans. If that + // ever leads to a circular import, we should consider moving this test to + // a different package or refactoring the test to not use plans. + p := &GRPCProvider{ + client: mockProviderClient(t), + ctx: context.Background(), + Addr: addrs.ImpliedProviderForUnqualifiedType("testencode"), + } + resp := p.GetProviderSchema() + + src := plans.NewChanges() + src.SyncWrapper().AppendResourceInstanceChange(&plans.ResourceInstanceChange{ + Addr: addrs.AbsResourceInstance{ + Module: addrs.RootModuleInstance, + Resource: addrs.ResourceInstance{ + Resource: addrs.Resource{ + Mode: addrs.ListResourceMode, + Type: "list", + Name: "test", + }, + Key: addrs.NoKey, + }, + }, + ProviderAddr: addrs.AbsProviderConfig{ + Provider: p.Addr, + }, + Change: plans.Change{ + Before: cty.NullVal(cty.Object(map[string]cty.Type{ + "config": cty.Object(map[string]cty.Type{ + "filter_attr": cty.String, + }), + "data": cty.List(cty.Object(map[string]cty.Type{ + "state": cty.Object(map[string]cty.Type{ + "resource_attr": cty.String, + }), + "identity": cty.Object(map[string]cty.Type{ + "id_attr": cty.String, + }), + })), + })), + After: cty.ObjectVal(map[string]cty.Value{ + "config": cty.ObjectVal(map[string]cty.Value{ + "filter_attr": cty.StringVal("value"), + }), + "data": cty.ListVal([]cty.Value{ + cty.ObjectVal(map[string]cty.Value{ + "state": cty.ObjectVal(map[string]cty.Value{ + "resource_attr": cty.StringVal("value"), + }), + "identity": cty.ObjectVal(map[string]cty.Value{ + "id_attr": cty.StringVal("value"), + }), + }), + }), + }), + }, + }) + _, err := src.Encode(&schemarepo.Schemas{ + Providers: map[addrs.Provider]providers.ProviderSchema{ + p.Addr: { + ResourceTypes: resp.ResourceTypes, + ListResourceTypes: resp.ListResourceTypes, + }, + }, + }) + if err != nil { + t.Fatalf("unexpected error encoding changes: %s", err) + } +} // Mock implementation of the ListResource stream client type mockListResourceStreamClient struct { @@ -1431,47 +1548,57 @@ func TestGRPCProvider_ListResource(t *testing.T) { resp := p.ListResource(request) checkDiags(t, resp.Diagnostics) - results := resp.Results - - // Verify that we received both events - if len(results) != 2 { - t.Fatalf("Expected 2 events, got %d", len(results)) + data := resp.Result.AsValueMap() + if _, ok := data["data"]; !ok { + t.Fatal("Expected 'data' key in result") } + // Verify that we received both events + if len(data["data"].AsValueSlice()) != 2 { + t.Fatalf("Expected 2 resources, got %d", len(data["data"].AsValueSlice())) + } + results := data["data"].AsValueSlice() // Verify first event - if results[0].DisplayName != "Test Resource 1" { - t.Errorf("Expected DisplayName 'Test Resource 1', got '%s'", results[0].DisplayName) + displayName := results[0].GetAttr("display_name") + if displayName.AsString() != "Test Resource 1" { + t.Errorf("Expected DisplayName 'Test Resource 1', got '%s'", displayName.AsString()) } expectedId1 := cty.ObjectVal(map[string]cty.Value{ "id_attr": cty.StringVal("id-1"), }) - if !results[0].Identity.RawEquals(expectedId1) { - t.Errorf("Expected Identity %#v, got %#v", expectedId1, results[0].Identity) + + identity := results[0].GetAttr("identity") + if !identity.RawEquals(expectedId1) { + t.Errorf("Expected Identity %#v, got %#v", expectedId1, identity) } // ResourceObject should be null for the first event as it wasn't provided - if !results[0].ResourceObject.IsNull() { - t.Errorf("Expected ResourceObject to be null, got %#v", results[0].ResourceObject) + resourceObject := results[0].GetAttr("state") + if !resourceObject.IsNull() { + t.Errorf("Expected ResourceObject to be null, got %#v", resourceObject) } // Verify second event - if results[1].DisplayName != "Test Resource 2" { - t.Errorf("Expected DisplayName 'Test Resource 2', got '%s'", results[1].DisplayName) + displayName = results[1].GetAttr("display_name") + if displayName.AsString() != "Test Resource 2" { + t.Errorf("Expected DisplayName 'Test Resource 2', got '%s'", displayName.AsString()) } expectedId2 := cty.ObjectVal(map[string]cty.Value{ "id_attr": cty.StringVal("id-2"), }) - if !results[1].Identity.RawEquals(expectedId2) { - t.Errorf("Expected Identity %#v, got %#v", expectedId2, results[1].Identity) + identity = results[1].GetAttr("identity") + if !identity.RawEquals(expectedId2) { + t.Errorf("Expected Identity %#v, got %#v", expectedId2, identity) } expectedResource := cty.ObjectVal(map[string]cty.Value{ "resource_attr": cty.StringVal("value"), }) - if !results[1].ResourceObject.RawEquals(expectedResource) { - t.Errorf("Expected ResourceObject %#v, got %#v", expectedResource, results[1].ResourceObject) + resourceObject = results[1].GetAttr("state") + if !resourceObject.RawEquals(expectedResource) { + t.Errorf("Expected ResourceObject %#v, got %#v", expectedResource, resourceObject) } } @@ -1479,6 +1606,7 @@ func TestGRPCProvider_ListResource_Error(t *testing.T) { client := mockProviderClient(t) p := &GRPCProvider{ client: client, + ctx: context.Background(), } // Test case where the provider returns an error @@ -1503,6 +1631,7 @@ func TestGRPCProvider_ListResource_Diagnostics(t *testing.T) { client := mockProviderClient(t) p := &GRPCProvider{ client: client, + ctx: context.Background(), } // Create a mock stream client that will return a resource event with diagnostics @@ -1544,12 +1673,12 @@ func TestGRPCProvider_ListResource_Diagnostics(t *testing.T) { resp := p.ListResource(request) checkDiags(t, resp.Diagnostics) - // Verify that we received one event with diagnostics - if len(resp.Results) != 1 { - t.Fatalf("Expected 1 event, got %d", len(resp.Results)) + data := resp.Result.AsValueMap() + if _, ok := data["data"]; !ok { + t.Fatal("Expected 'data' key in result") } - if !resp.Results[0].Diagnostics.HasWarnings() { + if !resp.Diagnostics.HasWarnings() { t.Fatal("Expected warning diagnostics, but got none") } } @@ -1558,6 +1687,7 @@ func TestGRPCProvider_ListResource_Limit(t *testing.T) { client := mockProviderClient(t) p := &GRPCProvider{ client: client, + ctx: context.Background(), } // Create a mock stream client that will return resource events @@ -1608,7 +1738,15 @@ func TestGRPCProvider_ListResource_Limit(t *testing.T) { resp := p.ListResource(request) checkDiags(t, resp.Diagnostics) - results := resp.Results + data := resp.Result.AsValueMap() + if _, ok := data["data"]; !ok { + t.Fatal("Expected 'data' key in result") + } + // Verify that we received both events + if len(data["data"].AsValueSlice()) != 2 { + t.Fatalf("Expected 2 resources, got %d", len(data["data"].AsValueSlice())) + } + results := data["data"].AsValueSlice() // Verify that we received both events if len(results) != 2 { diff --git a/internal/providers/provider.go b/internal/providers/provider.go index bd1b977fc4..dce445a7a7 100644 --- a/internal/providers/provider.go +++ b/internal/providers/provider.go @@ -142,7 +142,7 @@ type GetProviderSchemaResponse struct { // to its schema. EphemeralResourceTypes map[string]Schema - // ListResourceTypes maps the name of an ephemeral resource type to its + // ListResourceTypes maps the name of a list resource type to its // schema. ListResourceTypes map[string]Schema @@ -725,23 +725,8 @@ type CallFunctionResponse struct { Err error } -// ListResourceEvent represents a single resource from the list operation -type ListResourceEvent struct { - // Identity contains the resource identity data - Identity cty.Value - - // DisplayName is a human-readable name for the resource - DisplayName string - - // ResourceObject contains the full resource object if requested - ResourceObject cty.Value - - // Diagnostics contains any warnings or errors specific to this event - Diagnostics tfdiags.Diagnostics -} - type ListResourceResponse struct { - Results []ListResourceEvent + Result cty.Value Diagnostics tfdiags.Diagnostics } diff --git a/internal/states/module.go b/internal/states/module.go index c9ef6fa7d1..0beadd94d4 100644 --- a/internal/states/module.go +++ b/internal/states/module.go @@ -14,22 +14,13 @@ type Module struct { // Resources contains the state for each resource. The keys in this map are // an implementation detail and must not be used by outside callers. Resources map[string]*Resource - - // ListResources contains the state of the result of each list resource. - // The keys in this map are the config address of the list resource - // itself. The values are maps of the list resource instance addresses to the - // remote resource instance objects that are the result of the list - // resource. - // e.g "list.aws_instance.test" -> map["list.aws_instance.test[0]"] = ["resource.aws_instance.test[0]", "resource.aws_instance.test[1]"] - ListResources map[string]addrs.Map[addrs.AbsResourceInstance, *ResourceInstanceObject] } // NewModule constructs an empty module state for the given module address. func NewModule(addr addrs.ModuleInstance) *Module { return &Module{ - Addr: addr, - Resources: map[string]*Resource{}, - ListResources: map[string]addrs.Map[addrs.AbsResourceInstance, *ResourceInstanceObject]{}, + Addr: addr, + Resources: map[string]*Resource{}, } } diff --git a/internal/states/state.go b/internal/states/state.go index e5d25977e7..48a1d12af5 100644 --- a/internal/states/state.go +++ b/internal/states/state.go @@ -644,32 +644,3 @@ func (s *State) MoveModule(src, dst addrs.AbsModuleCall) { s.MoveModuleInstance(ms.Addr, newInst) } } - -func (s *State) SetListResourceInstance(addr addrs.AbsResourceInstance, val *ResourceInstanceObject) { - ms := s.Module(addr.Module) - if ms == nil { - panic(fmt.Sprintf("no state for module %s", addr.Module.String())) - } - key := addr.Resource.Resource.String() - if _, ok := ms.ListResources[key]; !ok { - ms.ListResources[key] = addrs.MakeMap[addrs.AbsResourceInstance, *ResourceInstanceObject]() - } - ms.ListResources[key].Put(addr, val) -} - -func (s *State) GetListResource(addr addrs.AbsResource) addrs.Map[addrs.AbsResourceInstance, *ResourceInstanceObject] { - ms := s.Module(addr.Module) - if ms == nil { - panic(fmt.Sprintf("no state for module %s", addr.Module.String())) - } - key := addr.Resource.String() - if _, ok := ms.ListResources[key]; !ok { - ms.ListResources[key] = addrs.MakeMap[addrs.AbsResourceInstance, *ResourceInstanceObject]() - } - return ms.ListResources[key] -} - -func (s *State) AllListResourceInstances() map[string]addrs.Map[addrs.AbsResourceInstance, *ResourceInstanceObject] { - // only root modules have list resources - return s.Modules[addrs.RootModuleInstance.String()].ListResources -} diff --git a/internal/states/state_deepcopy.go b/internal/states/state_deepcopy.go index f9ee7bf691..1a0c102362 100644 --- a/internal/states/state_deepcopy.go +++ b/internal/states/state_deepcopy.go @@ -62,15 +62,9 @@ func (ms *Module) DeepCopy() *Module { resources[k] = r.DeepCopy() } - listResources := make(map[string]addrs.Map[addrs.AbsResourceInstance, *ResourceInstanceObject], len(ms.ListResources)) - for k, r := range ms.ListResources { - listResources[k] = r - } - return &Module{ - Addr: ms.Addr, // technically mutable, but immutable by convention - Resources: resources, - ListResources: listResources, + Addr: ms.Addr, // technically mutable, but immutable by convention + Resources: resources, } } diff --git a/internal/states/sync.go b/internal/states/sync.go index ebd23eed93..da423876cb 100644 --- a/internal/states/sync.go +++ b/internal/states/sync.go @@ -441,21 +441,6 @@ func (s *SyncState) RecordCheckResults(checkState *checks.State) { s.state.CheckResults = newResults } -func (s *SyncState) SetListResourceInstance(addr addrs.AbsResourceInstance, val *ResourceInstanceObject) { - defer s.beginWrite()() - s.state.SetListResourceInstance(addr, val) -} - -func (s *SyncState) GetListResource(addr addrs.AbsResource) addrs.Map[addrs.AbsResourceInstance, *ResourceInstanceObject] { - defer s.beginWrite()() - return s.state.GetListResource(addr) -} - -func (s *SyncState) AllListResourceInstances() map[string]addrs.Map[addrs.AbsResourceInstance, *ResourceInstanceObject] { - defer s.beginWrite()() - return s.state.AllListResourceInstances() -} - // Lock acquires an explicit lock on the state, allowing direct read and write // access to the returned state object. The caller must call Unlock once // access is no longer needed, and then immediately discard the state pointer diff --git a/internal/terraform/context_plan.go b/internal/terraform/context_plan.go index db0571c1b7..b8d8127022 100644 --- a/internal/terraform/context_plan.go +++ b/internal/terraform/context_plan.go @@ -135,6 +135,10 @@ type PlanOpts struct { // Forget if set to true will cause the plan to forget all resources. This is // only allowd in the context of a destroy plan. Forget bool + + // Query is a boolean that indicates whether the plan is being + // generated for a query operation. + Query bool } // Plan generates an execution plan by comparing the given configuration @@ -885,8 +889,14 @@ func (c *Context) deferredResources(config *configs.Config, deferrals []*plans.D func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, opts *PlanOpts) (*Graph, walkOperation, tfdiags.Diagnostics) { var externalProviderConfigs map[addrs.RootProviderConfig]providers.Interface + var modeFilter addrs.ResourceMode if opts != nil { externalProviderConfigs = opts.ExternalProviders + // During a query operation, we only want to add the list-type resources + // to the graph. + if opts.Query { + modeFilter = addrs.ListResourceMode + } } switch mode := opts.Mode; mode { @@ -915,6 +925,7 @@ func (c *Context) planGraph(config *configs.Config, prevRunState *states.State, forgetModules: forgetModules, GenerateConfigPath: opts.GenerateConfigPath, SkipGraphValidation: c.graphOpts.SkipGraphValidation, + targetResourceMode: modeFilter, }).Build(addrs.RootModuleInstance) return graph, walkPlan, diags case plans.RefreshOnlyMode: diff --git a/internal/terraform/context_plan2_test.go b/internal/terraform/context_plan2_test.go index 6df225d5a8..4e69ef6d90 100644 --- a/internal/terraform/context_plan2_test.go +++ b/internal/terraform/context_plan2_test.go @@ -6848,67 +6848,134 @@ data "test_data_source" "foo" { } } -func TestContext2Plan_sensitiveOutput(t *testing.T) { - m := testModuleInline(t, map[string]string{ - "main.tf": ` -module "child" { - source = "./child" -} - -output "is_secret" { - // not only must the plan store the output as sensitive, it must also be - // evaluated as such - value = issensitive(module.child.secret) -} -`, - "./child/main.tf": ` -output "secret" { - sensitive = true - value = "test" -} -`, - }) - - ctx := testContext2(t, &ContextOpts{}) - - plan, diags := ctx.Plan(m, states.NewState(), DefaultPlanOpts) - tfdiags.AssertNoErrors(t, diags) - - expectedChanges := &plans.Changes{ - Outputs: []*plans.OutputChange{ - { - Addr: mustAbsOutputValue("module.child.output.secret"), - Change: plans.Change{ - Action: plans.Create, - BeforeIdentity: cty.NullVal(cty.DynamicPseudoType), - AfterIdentity: cty.NullVal(cty.DynamicPseudoType), - Before: cty.NullVal(cty.DynamicPseudoType), - After: cty.StringVal("test"), +// Testing that managed resources of type list are handled correctly +// - They must be referenced using the fully qualified name +// - We provide a hint to the user if they try to reference a resource of type list +// without using the fully qualified name +func TestContext2Plan_resourceNamedList(t *testing.T) { + schemaResp := getProviderSchemaResponseFromProviderSchema(&providerSchema{ + ResourceTypes: map[string]*configschema.Block{ + "list": { + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Computed: true, + }, }, - Sensitive: true, }, - { - Addr: mustAbsOutputValue("output.is_secret"), - Change: plans.Change{ - Action: plans.Create, - BeforeIdentity: cty.NullVal(cty.DynamicPseudoType), - AfterIdentity: cty.NullVal(cty.DynamicPseudoType), - Before: cty.NullVal(cty.DynamicPseudoType), - After: cty.True, + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "instance_type": { + Type: cty.String, + Computed: true, + }, + }, + }, + "test_child_resource": { + Attributes: map[string]*configschema.Attribute{ + "instance_type": { + Type: cty.String, + Computed: true, + }, }, }, }, - } - changes, err := plan.Changes.Decode(nil) - if err != nil { - t.Fatal(err) - } - - sort.SliceStable(changes.Outputs, func(i, j int) bool { - return changes.Outputs[i].Addr.String() < changes.Outputs[j].Addr.String() }) - if diff := cmp.Diff(expectedChanges, changes, ctydebug.CmpOptions); diff != "" { - t.Fatalf("unexpected changes: %s", diff) + cases := []struct { + name string + mainConfig string + diagCount int + expectedErrMsg []string + }{ + { + // We tried to reference a resource of type list without using the fully-qualified name. + // The error contains a hint to help the user. + name: "reference list block from resource", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "list" "test_resource1" { + provider = test + } + + resource "list" "test_resource2" { + provider = test + attr = list.test_resource1.attr + } + `, + diagCount: 1, + expectedErrMsg: []string{ + "Reference to undeclared resource", + "A list resource \"test_resource1\" \"attr\" has not been declared in the root module.", + "Did you mean the managed resource list.test_resource1? If so, please use the fully qualified name of the resource, e.g. resource.list.test_resource1", + }, + }, + { + // We are referencing a managed resource + // of type list using the resource.. syntax. This should be allowed. + name: "reference managed resource of type list using resource..", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "list" "test_resource" { + provider = test + attr = "bar" + } + + resource "list" "normal_resource" { + provider = test + attr = resource.list.test_resource.attr + } + `, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + configs := map[string]string{"main.tf": tc.mainConfig} + + m := testModuleInline(t, configs) + + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + provider.ConfigureProvider(providers.ConfigureProviderRequest{}) + provider.GetProviderSchemaResponse = schemaResp + + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + _, diags = ctx.Plan(m, states.NewState(), SimplePlanOpts(plans.NormalMode, nil)) + if len(diags) != tc.diagCount { + t.Fatalf("expected %d diagnostics, got %d \n -diags: %s", tc.diagCount, len(diags), diags) + } + + 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()) + } + } + } + + }) } } diff --git a/internal/terraform/context_plan_query_test.go b/internal/terraform/context_plan_query_test.go new file mode 100644 index 0000000000..714f003ba9 --- /dev/null +++ b/internal/terraform/context_plan_query_test.go @@ -0,0 +1,759 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "fmt" + "sort" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/internal/addrs" + "github.com/hashicorp/terraform/internal/configs/configschema" + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func TestContext2Plan_queryList(t *testing.T) { + schemaResp := getProviderSchemaResponseFromProviderSchema(&providerSchema{ + ResourceTypes: map[string]*configschema.Block{ + "list": { + Attributes: map[string]*configschema.Attribute{ + "attr": { + Type: cty.String, + Computed: true, + }, + }, + }, + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "instance_type": { + Type: cty.String, + Computed: true, + }, + }, + }, + "test_child_resource": { + Attributes: map[string]*configschema.Attribute{ + "instance_type": { + Type: cty.String, + Computed: true, + }, + }, + }, + }, + ListResourceTypes: map[string]*configschema.Block{ + "test_resource": getQueryTestSchema(), + "test_child_resource": getQueryTestSchema(), + }, + IdentityTypes: map[string]*configschema.Object{ + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + Nesting: configschema.NestingSingle, + }, + "test_child_resource": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + }, + Nesting: configschema.NestingSingle, + }, + }, + }) + + cases := []struct { + name string + mainConfig string + queryConfig string + diagCount int + expectedErrMsg []string + assertState func(*states.State) + assertChanges func(providers.ProviderSchema, *plans.ChangesSrc) + listResourceFn func(request providers.ListResourceRequest) providers.ListResourceResponse + }{ + { + name: "valid list reference", + 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 + + filter = { + attr = var.input + } + } + + list "test_resource" "test2" { + provider = test + + filter = { + attr = list.test_resource.test.data[0].state.instance_type + } + } + `, + 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")}), + cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-789012")}), + } + 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", "list.test_resource.test2"} + actualResources := make([]string, 0) + for _, change := range changes.Resources { + 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) + } + + if cs.Change.Action != plans.Read { + t.Fatalf("expected action to be Read, got %s", cs.Change.Action) + } + + // Verify instance types + expectedTypes := []string{"ami-123456", "ami-654321", "ami-789012"} + actualTypes := make([]string, 0) + obj := cs.After.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: "valid list instance reference", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + variable "input" { + type = string + default = "foo" + } + + list "test_resource" "test" { + count = 1 + provider = test + + filter = { + attr = var.input + } + } + + list "test_resource" "test2" { + provider = test + + filter = { + attr = list.test_resource.test[0].data[0].state.instance_type + } + } + `, + 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[0]", "list.test_resource.test2"} + actualResources := make([]string, 0) + for _, change := range changes.Resources { + 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) + } + + if cs.Change.Action != plans.Read { + t.Fatalf("expected action to be Read, got %s", cs.Change.Action) + } + + // Verify instance types + expectedTypes := []string{"ami-123456", "ami-654321"} + actualTypes := make([]string, 0) + obj := cs.After.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: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + variable "input" { + type = string + default = "foo" + } + + list "test_resource" "test" { + provider = test + + filter = { + attr = var.input + } + } + + list "test_resource" "test2" { + provider = test + + filter = { + attr = list.test_resource.test.state.instance_type + } + } + `, + diagCount: 1, + expectedErrMsg: []string{ + "Invalid list resource traversal", + "The first step in the traversal for a list resource must be an attribute \"data\"", + }, + }, + { + // Test referencing a non-existent list resource + name: "reference non-existent list resource", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + list "test_resource" "test" { + provider = test + + filter = { + attr = list.non_existent.attr + } + } + `, + diagCount: 1, + expectedErrMsg: []string{ + "A list resource \"non_existent\" \"attr\" has not been declared in the root module.", + }, + }, + { + // Test referencing a list resource with invalid attribute + name: "reference list resource with invalid attribute", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + list "test_resource" "test" { + provider = test + + filter = { + attr = "valid" + } + } + + list "test_resource" "another" { + provider = test + + filter = { + attr = list.test_resource.test.data[0].state.invalid_attr + } + } + `, + diagCount: 1, + expectedErrMsg: []string{ + "Unsupported attribute: This object has no argument, nested block, or exported attribute named \"invalid_attr\".", + }, + listResourceFn: func(request providers.ListResourceRequest) providers.ListResourceResponse { + madeUp := []cty.Value{ + cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-123456")}), + } + 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)} + }, + }, + { + name: "circular reference between list resources", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + list "test_resource" "test1" { + provider = test + + filter = { + attr = list.test_resource.test2.data[0].state.id + } + } + + list "test_resource" "test2" { + provider = test + + filter = { + attr = list.test_resource.test1.data[0].state.id + } + } + `, + diagCount: 1, + expectedErrMsg: []string{ + "Cycle: list.test_resource", + }, + }, + { + // Test complex expression with list reference + name: "complex expression with list reference", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + variable "test_var" { + type = string + default = "default" + } + + list "test_resource" "test1" { + provider = test + + filter = { + attr = var.test_var + } + } + + list "test_resource" "test2" { + provider = test + + filter = { + attr = length(list.test_resource.test1.data) > 0 ? list.test_resource.test1.data[0].state.instance_type : var.test_var + } + } + `, + listResourceFn: func(request providers.ListResourceRequest) providers.ListResourceResponse { + madeUp := []cty.Value{ + cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-123456")}), + } + 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.test1", "list.test_resource.test2"} + actualResources := make([]string, 0) + for _, change := range changes.Resources { + 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) + } + + if cs.Change.Action != plans.Read { + t.Fatalf("expected action to be Read, got %s", cs.Change.Action) + } + + // Verify instance types + expectedTypes := []string{"ami-123456"} + actualTypes := make([]string, 0) + obj := cs.After.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) + } + }, + }, + { + // Test list reference with index but without data field + name: "list reference with index but without data field", + mainConfig: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + `, + queryConfig: ` + list "test_resource" "test1" { + for_each = toset(["foo", "bar"]) + provider = test + + filter = { + attr = each.value + } + } + + list "test_resource" "test2" { + provider = test + for_each = list.test_resource.test1 + + filter = { + attr = each.value.data[0].state.instance_type + } + } + `, + listResourceFn: func(request providers.ListResourceRequest) providers.ListResourceResponse { + madeUp := []cty.Value{ + cty.ObjectVal(map[string]cty.Value{"instance_type": cty.StringVal("ami-123456")}), + } + 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.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.Resources { + 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) + } + + if cs.Change.Action != plans.Read { + t.Fatalf("expected action to be Read, got %s", cs.Change.Action) + } + + // Verify instance types + expectedTypes := []string{"ami-123456"} + actualTypes := make([]string, 0) + obj := cs.After.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) + } + }, + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + configs := map[string]string{"main.tf": tc.mainConfig} + if tc.queryConfig != "" { + configs["main.tfquery.hcl"] = tc.queryConfig + } + + mod := testModuleInline(t, configs) + + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + provider.ConfigureProvider(providers.ConfigureProviderRequest{}) + provider.GetProviderSchemaResponse = schemaResp + var requestConfigs = make(map[string]cty.Value) + provider.ListResourceFn = func(request providers.ListResourceRequest) providers.ListResourceResponse { + requestConfigs[request.TypeName] = request.Config + fn := tc.listResourceFn + if fn == nil { + return provider.ListResourceResponse + } + return fn(request) + } + + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + plan, diags := ctx.Plan(mod, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + Query: true, + }) + if len(diags) != tc.diagCount { + t.Fatalf("expected %d diagnostics, got %d \n -diags: %s", tc.diagCount, len(diags), diags) + } + + if tc.assertChanges != nil { + sch, err := ctx.Schemas(mod, states.NewState()) + if err != nil { + t.Fatalf("failed to get schemas: %s", err) + } + 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()) + } + } + } + + }) + } +} diff --git a/internal/terraform/context_validate_test.go b/internal/terraform/context_validate_test.go index 3f9862389f..1045ec59a9 100644 --- a/internal/terraform/context_validate_test.go +++ b/internal/terraform/context_validate_test.go @@ -3135,7 +3135,7 @@ func TestContext2Validate_queryList(t *testing.T) { provider = test filter = { - attr = list.test_resource.test.data[0].instance_type + attr = list.test_resource.test.data[0].state.instance_type } } `, @@ -3171,7 +3171,7 @@ func TestContext2Validate_queryList(t *testing.T) { provider = test filter = { - attr = list.test_resource.test[0].data[0].instance_type + attr = list.test_resource.test[0].data[0].state.instance_type } } `, @@ -3206,7 +3206,7 @@ func TestContext2Validate_queryList(t *testing.T) { provider = test filter = { - attr = list.test_resource.test.instance_type + attr = list.test_resource.test.state.instance_type } } `, @@ -3347,7 +3347,7 @@ func TestContext2Validate_queryList(t *testing.T) { provider = test filter = { - attr = list.test_resource.test.data[0].invalid_attr + attr = list.test_resource.test.data[0].state.invalid_attr } } `, @@ -3373,7 +3373,7 @@ func TestContext2Validate_queryList(t *testing.T) { provider = test filter = { - attr = list.test_resource.test2.data[0].id + attr = list.test_resource.test2.data[0].state.id } } @@ -3381,7 +3381,7 @@ func TestContext2Validate_queryList(t *testing.T) { provider = test filter = { - attr = list.test_resource.test1.data[0].id + attr = list.test_resource.test1.data[0].state.id } } `, @@ -3421,7 +3421,7 @@ func TestContext2Validate_queryList(t *testing.T) { provider = test filter = { - attr = length(list.test_resource.test1.data) > 0 ? list.test_resource.test1.data[0].instance_type : var.test_var + attr = length(list.test_resource.test1.data) > 0 ? list.test_resource.test1.data[0].state.instance_type : var.test_var } } `, @@ -3554,6 +3554,10 @@ func getQueryTestSchema() *configschema.Block { }, }, }, + "data": { + Computed: true, + Type: cty.DynamicPseudoType, + }, }, } } diff --git a/internal/terraform/eval_context_builtin_test.go b/internal/terraform/eval_context_builtin_test.go index 60ba0b84d2..893c6f0345 100644 --- a/internal/terraform/eval_context_builtin_test.go +++ b/internal/terraform/eval_context_builtin_test.go @@ -12,20 +12,24 @@ import ( "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" + "github.com/hashicorp/terraform/internal/instances" + "github.com/hashicorp/terraform/internal/namedvals" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" + "github.com/hashicorp/terraform/internal/resources/ephemeral" + "github.com/hashicorp/terraform/internal/states" ) func TestBuiltinEvalContextProviderInput(t *testing.T) { var lock sync.Mutex cache := make(map[string]map[string]cty.Value) - ctx1 := testBuiltinEvalContext(t) + ctx1 := defaultTestCtx(t) ctx1 = ctx1.withScope(evalContextModuleInstance{Addr: addrs.RootModuleInstance}).(*BuiltinEvalContext) ctx1.ProviderInputConfig = cache ctx1.ProviderLock = &lock - ctx2 := testBuiltinEvalContext(t) + ctx2 := defaultTestCtx(t) ctx2 = ctx2.withScope(evalContextModuleInstance{Addr: addrs.RootModuleInstance.Child("child", addrs.NoKey)}).(*BuiltinEvalContext) ctx2.ProviderInputConfig = cache ctx2.ProviderLock = &lock @@ -61,7 +65,7 @@ func TestBuildingEvalContextInitProvider(t *testing.T) { testP := &testing_provider.MockProvider{} - ctx := testBuiltinEvalContext(t) + ctx := defaultTestCtx(t) ctx = ctx.withScope(evalContextModuleInstance{Addr: addrs.RootModuleInstance}).(*BuiltinEvalContext) ctx.ProviderLock = &lock ctx.ProviderCache = make(map[string]providers.Interface) @@ -101,6 +105,41 @@ func TestBuildingEvalContextInitProvider(t *testing.T) { } } -func testBuiltinEvalContext(t *testing.T) *BuiltinEvalContext { - return &BuiltinEvalContext{} +var defaultTestCtx = func(t *testing.T) *BuiltinEvalContext { + return testBuiltinEvalContext(t, walkPlan, nil, nil, nil) +} + +func testBuiltinEvalContext(t *testing.T, op walkOperation, cfg *configs.Config, state *states.State, valState *namedvals.State) *BuiltinEvalContext { + t.Helper() + if state == nil { + state = states.NewState() + } + if cfg == nil { + cfg = configs.NewEmptyConfig() + } + if valState == nil { + valState = namedvals.NewState() + } + ex := instances.NewExpander(nil) + eph := ephemeral.NewResources() + ev := &Evaluator{ + Config: cfg, + State: state.SyncWrapper(), + Operation: op, + NamedValues: valState, + Instances: ex, + EphemeralResources: eph, + } + return &BuiltinEvalContext{ + Evaluator: ev, + StateValue: state.SyncWrapper(), + PrevRunStateValue: state.DeepCopy().SyncWrapper(), + RefreshStateValue: state.DeepCopy().SyncWrapper(), + NamedValuesValue: valState, + ProviderLock: &sync.Mutex{}, + ProviderCache: make(map[string]providers.Interface), + ProviderFuncCache: make(map[string]providers.Interface), + InstanceExpanderValue: ex, + EphemeralResourcesValue: eph, + } } diff --git a/internal/terraform/evaluate.go b/internal/terraform/evaluate.go index 573b937bf0..5beb3f1c9b 100644 --- a/internal/terraform/evaluate.go +++ b/internal/terraform/evaluate.go @@ -816,7 +816,7 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi switch d.Operation { case walkValidate: return cty.DynamicVal, diags - case walkQuery: + case walkPlan: // continue default: return cty.DynamicVal, diags.Append(&hcl.Diagnostic{ @@ -827,16 +827,6 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi }) } - // The provider result of a list resource is always a tuple, but - // we will wrap that tuple in an object with a single attribute "data", - // so that we can differentiate between a list resource instance (list.aws_instance.test[index]) - // and the elements of the result of a list resource instance (list.aws_instance.test.data[index]) - wrappedVal := func(v *states.ResourceInstanceObject) cty.Value { - return cty.ObjectVal(map[string]cty.Value{ - "data": v.Value, - "identity": v.Identity, - }) - } lAddr := config.Addr() mAddr := addrs.Resource{ Mode: addrs.ManagedResourceMode, @@ -856,9 +846,9 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi return cty.DynamicVal, diags } resourceType := resourceSchema.Body.ImpliedType() - instances := d.Evaluator.State.GetListResource(lAddr.Absolute(d.ModulePath)) + changes := d.Evaluator.Changes.GetChangesForAbsResource(lAddr.Absolute(d.ModulePath)) - if len(instances.Values()) == 0 { + if len(changes) == 0 { // Since we know there are no instances, return an empty container of the expected type. switch { case config.Count != nil: @@ -875,18 +865,18 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi case config.Count != nil: // figure out what the last index we have is length := -1 - for _, inst := range instances.Elems { - if intKey, ok := inst.Key.Resource.Key.(addrs.IntKey); ok { + for _, inst := range changes { + if intKey, ok := inst.Addr.Resource.Key.(addrs.IntKey); ok { length = max(int(intKey)+1, length) } } if length > 0 { vals := make([]cty.Value, length) - for _, inst := range instances.Elems { - key := inst.Key.Resource.Key + for _, inst := range changes { + key := inst.Addr.Resource.Key if intKey, ok := key.(addrs.IntKey); ok { - vals[int(intKey)] = wrappedVal(inst.Value) + vals[int(intKey)] = inst.After } } @@ -902,10 +892,10 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi } case config.ForEach != nil: vals := make(map[string]cty.Value) - for _, inst := range instances.Elems { - key := inst.Key.Resource.Key + for _, inst := range changes { + key := inst.Addr.Resource.Key if strKey, ok := key.(addrs.StringKey); ok { - vals[string(strKey)] = wrappedVal(inst.Value) + vals[string(strKey)] = inst.After } } @@ -918,18 +908,15 @@ func (d *evaluationStateData) getListResource(config *configs.Resource, rng tfdi } else { ret = cty.EmptyObjectVal } - - return ret, diags default: - inst, ok := instances.GetOk(lAddr.Absolute(d.ModulePath).Instance(addrs.NoKey)) - if !ok { - // if the instance is missing, insert an unknown value - inst = &states.ResourceInstanceObject{ - Value: cty.UnknownVal(resourceType), - } + if len(changes) <= 0 { + // if the instance is missing, insert an empty tuple + ret = cty.ObjectVal(map[string]cty.Value{ + "data": cty.EmptyTupleVal, + }) + } else { + ret = changes[0].After } - - ret = wrappedVal(inst) } return ret, diags diff --git a/internal/terraform/evaluate_valid.go b/internal/terraform/evaluate_valid.go index d5595da116..16315abd5d 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 %T instead.`, modeAdjective, remain[0]), + 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]), Subject: rng.ToHCL().Ptr(), }) return diags @@ -315,7 +315,32 @@ 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 %T instead.`, modeAdjective, remain[0]), + Detail: fmt.Sprintf(`The second step in the traversal for a %s resource must be an index, but got %q instead.`, modeAdjective, remain[0]), + Subject: rng.ToHCL().Ptr(), + }) + return diags + } + // remove the index, and now we have the rest of the traversal, + // which we can validate against the schema + remain = remain[1:] + } + + if len(remain) > 0 { // i.e list.aws_instance.foo.data[count.index].state + stateOrIdent, ok := remain[0].(hcl.TraverseAttr) + if !ok { + 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]), + Subject: rng.ToHCL().Ptr(), + }) + return diags + } + if stateOrIdent.Name != "state" && stateOrIdent.Name != "identity" { + 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), Subject: rng.ToHCL().Ptr(), }) return diags diff --git a/internal/terraform/graph_builder_plan.go b/internal/terraform/graph_builder_plan.go index 0b457f888c..0a9aca52d6 100644 --- a/internal/terraform/graph_builder_plan.go +++ b/internal/terraform/graph_builder_plan.go @@ -5,7 +5,6 @@ package terraform import ( "log" - "slices" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" @@ -111,6 +110,11 @@ type PlanGraphBuilder struct { // SkipGraphValidation indicates whether the graph builder should skip // validation of the graph. SkipGraphValidation bool + + // targetResourceMode is the resource mode to select for the graph. + // If set, this is used to filter out resources that are not of the given mode. + // Otherwise, all resources are included. + targetResourceMode addrs.ResourceMode } // See GraphBuilder @@ -146,10 +150,9 @@ func (b *PlanGraphBuilder) Steps() []GraphTransformer { destroy: b.Operation == walkDestroy || b.Operation == walkPlanDestroy, importTargets: b.ImportTargets, + ModeFilter: b.Operation == walkPlan && b.targetResourceMode != addrs.InvalidResourceMode, + Mode: b.targetResourceMode, - // the validate walk also needs to include query-related nodes. - includeQuery: slices.Contains([]walkOperation{walkValidate, walkQuery}, b.Operation), - // We only want to generate config during a plan operation. generateConfigPathForImportTargets: b.GenerateConfigPath, }, diff --git a/internal/terraform/graph_walk_operation.go b/internal/terraform/graph_walk_operation.go index 1e92263356..9100c8b881 100644 --- a/internal/terraform/graph_walk_operation.go +++ b/internal/terraform/graph_walk_operation.go @@ -17,5 +17,4 @@ const ( walkDestroy walkImport walkEval // used just to prepare EvalContext for expression evaluation, with no other actions - walkQuery ) diff --git a/internal/terraform/node_resource_plan_instance.go b/internal/terraform/node_resource_plan_instance.go index b0e3702404..2f38df5fa1 100644 --- a/internal/terraform/node_resource_plan_instance.go +++ b/internal/terraform/node_resource_plan_instance.go @@ -79,6 +79,8 @@ func (n *NodePlannableResourceInstance) Execute(ctx EvalContext, op walkOperatio return n.dataResourceExecute(ctx) case addrs.EphemeralResourceMode: return n.ephemeralResourceExecute(ctx) + case addrs.ListResourceMode: + return n.listResourceExecute(ctx) default: panic(fmt.Errorf("unsupported resource mode %s", n.Config.Mode)) } diff --git a/internal/terraform/node_resource_plan_instance_query.go b/internal/terraform/node_resource_plan_instance_query.go new file mode 100644 index 0000000000..610758f54a --- /dev/null +++ b/internal/terraform/node_resource_plan_instance_query.go @@ -0,0 +1,90 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package terraform + +import ( + "fmt" + "log" + + "github.com/hashicorp/terraform/internal/plans" + "github.com/hashicorp/terraform/internal/providers" + "github.com/hashicorp/terraform/internal/states" + "github.com/hashicorp/terraform/internal/tfdiags" + "github.com/zclconf/go-cty/cty" +) + +func (n *NodePlannableResourceInstance) listResourceExecute(ctx EvalContext) (diags tfdiags.Diagnostics) { + log.Printf("[TRACE] NodePlannableResourceInstance: listing resources for %s", n.Addr) + config := n.Config + addr := n.ResourceInstanceAddr() + provider, providerSchema, err := getProvider(ctx, n.ResolvedProvider) + diags = diags.Append(err) + if diags.HasErrors() { + return diags + } + + // validate self ref + diags = diags.Append(validateSelfRef(addr.Resource, config.Config, providerSchema)) + if diags.HasErrors() { + return diags + } + + keyData := EvalDataForInstanceKey(addr.Resource.Key, nil) + if config.ForEach != nil { + forEach, _, _ := evaluateForEachExpression(config.ForEach, ctx, false) + keyData = EvalDataForInstanceKey(addr.Resource.Key, forEach) + } + + // evaluate the list config block + var configDiags tfdiags.Diagnostics + configVal, _, configDiags := ctx.EvaluateBlock(config.Config, n.Schema.Body, nil, keyData) + diags = diags.Append(configDiags) + if diags.HasErrors() { + return diags + } + + // Unmark before sending to provider + unmarkedConfigVal, _ := configVal.UnmarkDeepWithPaths() + configKnown := configVal.IsWhollyKnown() + if !configKnown { + diags = diags.Append(fmt.Errorf("config is not known")) + return diags + } + + log.Printf("[TRACE] NodePlannableResourceInstance: Re-validating config for %s", n.Addr) + validateResp := provider.ValidateListResourceConfig( + providers.ValidateListResourceConfigRequest{ + TypeName: n.Config.Type, + Config: unmarkedConfigVal, + }, + ) + diags = diags.Append(validateResp.Diagnostics.InConfigBody(config.Config, n.Addr.String())) + if diags.HasErrors() { + return diags + } + + // If we get down here then our configuration is complete and we're ready + // to actually call the provider to list the data. + resp := provider.ListResource(providers.ListResourceRequest{ + TypeName: n.Config.Type, + Config: unmarkedConfigVal, + }) + if resp.Diagnostics != nil { + return diags.Append(resp.Diagnostics.InConfigBody(config.Config, n.Addr.String())) + } + + change := &plans.ResourceInstanceChange{ + Addr: n.Addr, + ProviderAddr: n.ResolvedProvider, + Change: plans.Change{ + Action: plans.Read, + Before: cty.DynamicVal, + After: resp.Result, + }, + DeposedKey: states.NotDeposed, + } + + ctx.Changes().AppendResourceInstanceChange(change) + return diags +} diff --git a/internal/terraform/transform_config.go b/internal/terraform/transform_config.go index babe00ae43..82c5babc33 100644 --- a/internal/terraform/transform_config.go +++ b/internal/terraform/transform_config.go @@ -38,9 +38,6 @@ type ConfigTransformer struct { // some actions are skipped during the destroy process destroy bool - // includeQuery is true if the graph should include query nodes. - includeQuery bool - // importTargets specifies a slice of addresses that will have state // imported for them. importTargets []*ImportTarget @@ -103,9 +100,6 @@ func (t *ConfigTransformer) transformSingle(g *Graph, config *configs.Config) er for _, r := range module.DataResources { allResources = append(allResources, r) } - } - - if t.includeQuery { for _, r := range module.ListResources { allResources = append(allResources, r) } diff --git a/internal/terraform/walkoperation_string.go b/internal/terraform/walkoperation_string.go index 0eda8e1a4d..799d4dae27 100644 --- a/internal/terraform/walkoperation_string.go +++ b/internal/terraform/walkoperation_string.go @@ -16,12 +16,11 @@ func _() { _ = x[walkDestroy-5] _ = x[walkImport-6] _ = x[walkEval-7] - _ = x[walkQuery-8] } -const _walkOperation_name = "walkInvalidwalkApplywalkPlanwalkPlanDestroywalkValidatewalkDestroywalkImportwalkEvalwalkQuery" +const _walkOperation_name = "walkInvalidwalkApplywalkPlanwalkPlanDestroywalkValidatewalkDestroywalkImportwalkEval" -var _walkOperation_index = [...]uint8{0, 11, 20, 28, 43, 55, 66, 76, 84, 93} +var _walkOperation_index = [...]uint8{0, 11, 20, 28, 43, 55, 66, 76, 84} func (i walkOperation) String() string { if i >= walkOperation(len(_walkOperation_index)-1) {