Schema representation of list block config and results (#37209)

This commit is contained in:
Samsondeen 2025-06-10 20:08:54 +02:00 committed by GitHub
parent b3d7dae793
commit 685ff9f192
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1539 additions and 282 deletions

View file

@ -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.

View file

@ -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))

View file

@ -333,7 +333,7 @@ func TestObjectInternalValidate(t *testing.T) {
Errs []string
}{
"empty": {
&Object{},
&Object{Nesting: NestingSingle},
[]string{},
},
"valid": {

View file

@ -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))
}

View file

@ -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))
}

View file

@ -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 {

View file

@ -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))
}

View file

@ -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 {

View file

@ -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 {

View file

@ -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
}

View file

@ -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{},
}
}

View file

@ -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
}

View file

@ -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,
}
}

View file

@ -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

View file

@ -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:

View file

@ -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.<block>.<name> syntax. This should be allowed.
name: "reference managed resource of type list using resource.<block>.<name>",
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())
}
}
}
})
}
}

View file

@ -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())
}
}
}
})
}
}

View file

@ -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,
},
},
}
}

View file

@ -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,
}
}

View file

@ -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

View file

@ -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

View file

@ -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,
},

View file

@ -17,5 +17,4 @@ const (
walkDestroy
walkImport
walkEval // used just to prepare EvalContext for expression evaluation, with no other actions
walkQuery
)

View file

@ -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))
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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) {