diff --git a/internal/policy/callback/callback.go b/internal/policy/callback/callback.go index 6b0fd118707f..78d90596ba4f 100644 --- a/internal/policy/callback/callback.go +++ b/internal/policy/callback/callback.go @@ -11,9 +11,17 @@ import ( "github.com/zclconf/go-cty/cty" ) +type RelatedAttributePair struct { + SourceAttribute string + RelatedAttribute string +} + type Functions struct { - GetResources func(ctx context.Context, resource string, attrs cty.Value) ([]cty.Value, bool, error) - GetDataSource func(ctx context.Context, datasource string, attrs cty.Value) (cty.Value, bool, error) + GetResources func(ctx context.Context, resource string, attrs cty.Value) ([]cty.Value, bool, error) + // RelatedResources returns candidate resources whose target attributes + // directly traverse to, or statically equal, the current resource attributes. + RelatedResources func(ctx context.Context, resource string, pairs []RelatedAttributePair) ([]cty.Value, bool, error) + GetDataSource func(ctx context.Context, datasource string, attrs cty.Value) (cty.Value, bool, error) } // Registry is an interface for managing callback functions for resources and diff --git a/internal/policy/callback/server.go b/internal/policy/callback/server.go index 51d0cfccda0f..f3b3a09b6d90 100644 --- a/internal/policy/callback/server.go +++ b/internal/policy/callback/server.go @@ -57,6 +57,40 @@ func (s *Server) GetResources(ctx context.Context, request *proto.GetResourcesRe }, nil } +func (s *Server) RelatedResources(ctx context.Context, request *proto.RelatedResourcesRequest) (*proto.RelatedResourcesResponse, error) { + functions, ok := s.Registry.Get(request.EvaluationRequestId) + if !ok { + err := fmt.Errorf("no callback registered for ID %d (request type: %s)", request.EvaluationRequestId, request.Type) + return nil, err + } + pairs := make([]RelatedAttributePair, 0, len(request.AttributePairs)) + for _, pair := range request.AttributePairs { + pairs = append(pairs, RelatedAttributePair{ + SourceAttribute: pair.SourceAttribute, + RelatedAttribute: pair.RelatedAttribute, + }) + } + resources, isPartialResult, err := functions.RelatedResources(ctx, request.Type, pairs) + if err != nil { + return nil, err + } + + results := make([][]byte, 0, len(resources)) + for _, resource := range resources { + result, err := msgpack.Marshal(resource, cty.DynamicPseudoType) + if err != nil { + err = fmt.Errorf("failed to serialize resource: %w", err) + return nil, err + } + results = append(results, result) + } + + return &proto.RelatedResourcesResponse{ + Results: results, + Partial: isPartialResult, + }, nil +} + func (s *Server) GetDataSource(ctx context.Context, request *proto.GetDataSourceRequest) (*proto.GetDataSourceResponse, error) { config, err := msgpack.Unmarshal(request.Config, cty.DynamicPseudoType) if err != nil { diff --git a/internal/policy/proto/callback.pb.go b/internal/policy/proto/callback.pb.go index a894e6b20594..8aa9ca6dfdcc 100644 --- a/internal/policy/proto/callback.pb.go +++ b/internal/policy/proto/callback.pb.go @@ -140,6 +140,122 @@ func (x *GetResourcesResponse) GetPartial() bool { return false } +type RelatedResourcesRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` + AttributePairs []*RelatedResourcesRequest_AttributePair `protobuf:"bytes,2,rep,name=attribute_pairs,json=attributePairs,proto3" json:"attribute_pairs,omitempty"` + // evaluation_request_id is the ID of the policy evaluation request that is + // making this callback request. + EvaluationRequestId uint32 `protobuf:"varint,3,opt,name=evaluation_request_id,json=evaluationRequestId,proto3" json:"evaluation_request_id,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelatedResourcesRequest) Reset() { + *x = RelatedResourcesRequest{} + mi := &file_callback_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelatedResourcesRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelatedResourcesRequest) ProtoMessage() {} + +func (x *RelatedResourcesRequest) ProtoReflect() protoreflect.Message { + mi := &file_callback_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelatedResourcesRequest.ProtoReflect.Descriptor instead. +func (*RelatedResourcesRequest) Descriptor() ([]byte, []int) { + return file_callback_proto_rawDescGZIP(), []int{2} +} + +func (x *RelatedResourcesRequest) GetType() string { + if x != nil { + return x.Type + } + return "" +} + +func (x *RelatedResourcesRequest) GetAttributePairs() []*RelatedResourcesRequest_AttributePair { + if x != nil { + return x.AttributePairs + } + return nil +} + +func (x *RelatedResourcesRequest) GetEvaluationRequestId() uint32 { + if x != nil { + return x.EvaluationRequestId + } + return 0 +} + +type RelatedResourcesResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Results [][]byte `protobuf:"bytes,1,rep,name=results,proto3" json:"results,omitempty"` + // partial means that Terraform could not conclusively determine whether the + // relationship exists for all candidates. + Partial bool `protobuf:"varint,2,opt,name=partial,proto3" json:"partial,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelatedResourcesResponse) Reset() { + *x = RelatedResourcesResponse{} + mi := &file_callback_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelatedResourcesResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelatedResourcesResponse) ProtoMessage() {} + +func (x *RelatedResourcesResponse) ProtoReflect() protoreflect.Message { + mi := &file_callback_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelatedResourcesResponse.ProtoReflect.Descriptor instead. +func (*RelatedResourcesResponse) Descriptor() ([]byte, []int) { + return file_callback_proto_rawDescGZIP(), []int{3} +} + +func (x *RelatedResourcesResponse) GetResults() [][]byte { + if x != nil { + return x.Results + } + return nil +} + +func (x *RelatedResourcesResponse) GetPartial() bool { + if x != nil { + return x.Partial + } + return false +} + type GetDataSourceRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Type string `protobuf:"bytes,1,opt,name=type,proto3" json:"type,omitempty"` @@ -153,7 +269,7 @@ type GetDataSourceRequest struct { func (x *GetDataSourceRequest) Reset() { *x = GetDataSourceRequest{} - mi := &file_callback_proto_msgTypes[2] + mi := &file_callback_proto_msgTypes[4] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -165,7 +281,7 @@ func (x *GetDataSourceRequest) String() string { func (*GetDataSourceRequest) ProtoMessage() {} func (x *GetDataSourceRequest) ProtoReflect() protoreflect.Message { - mi := &file_callback_proto_msgTypes[2] + mi := &file_callback_proto_msgTypes[4] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -178,7 +294,7 @@ func (x *GetDataSourceRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDataSourceRequest.ProtoReflect.Descriptor instead. func (*GetDataSourceRequest) Descriptor() ([]byte, []int) { - return file_callback_proto_rawDescGZIP(), []int{2} + return file_callback_proto_rawDescGZIP(), []int{4} } func (x *GetDataSourceRequest) GetType() string { @@ -213,7 +329,7 @@ type GetDataSourceResponse struct { func (x *GetDataSourceResponse) Reset() { *x = GetDataSourceResponse{} - mi := &file_callback_proto_msgTypes[3] + mi := &file_callback_proto_msgTypes[5] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -225,7 +341,7 @@ func (x *GetDataSourceResponse) String() string { func (*GetDataSourceResponse) ProtoMessage() {} func (x *GetDataSourceResponse) ProtoReflect() protoreflect.Message { - mi := &file_callback_proto_msgTypes[3] + mi := &file_callback_proto_msgTypes[5] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -238,7 +354,7 @@ func (x *GetDataSourceResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use GetDataSourceResponse.ProtoReflect.Descriptor instead. func (*GetDataSourceResponse) Descriptor() ([]byte, []int) { - return file_callback_proto_rawDescGZIP(), []int{3} + return file_callback_proto_rawDescGZIP(), []int{5} } func (x *GetDataSourceResponse) GetResult() []byte { @@ -255,6 +371,58 @@ func (x *GetDataSourceResponse) GetDeferred() bool { return false } +type RelatedResourcesRequest_AttributePair struct { + state protoimpl.MessageState `protogen:"open.v1"` + SourceAttribute string `protobuf:"bytes,1,opt,name=source_attribute,json=sourceAttribute,proto3" json:"source_attribute,omitempty"` + RelatedAttribute string `protobuf:"bytes,2,opt,name=related_attribute,json=relatedAttribute,proto3" json:"related_attribute,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *RelatedResourcesRequest_AttributePair) Reset() { + *x = RelatedResourcesRequest_AttributePair{} + mi := &file_callback_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *RelatedResourcesRequest_AttributePair) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*RelatedResourcesRequest_AttributePair) ProtoMessage() {} + +func (x *RelatedResourcesRequest_AttributePair) ProtoReflect() protoreflect.Message { + mi := &file_callback_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use RelatedResourcesRequest_AttributePair.ProtoReflect.Descriptor instead. +func (*RelatedResourcesRequest_AttributePair) Descriptor() ([]byte, []int) { + return file_callback_proto_rawDescGZIP(), []int{2, 0} +} + +func (x *RelatedResourcesRequest_AttributePair) GetSourceAttribute() string { + if x != nil { + return x.SourceAttribute + } + return "" +} + +func (x *RelatedResourcesRequest_AttributePair) GetRelatedAttribute() string { + if x != nil { + return x.RelatedAttribute + } + return "" +} + var File_callback_proto protoreflect.FileDescriptor const file_callback_proto_rawDesc = "" + @@ -268,6 +436,16 @@ const file_callback_proto_rawDesc = "" + "\x15evaluation_request_id\x18\x03 \x01(\rR\x13evaluationRequestId\"J\n" + "\x14GetResourcesResponse\x12\x18\n" + "\aresults\x18\x01 \x03(\fR\aresults\x12\x18\n" + + "\apartial\x18\x02 \x01(\bR\apartial\"\xa1\x02\n" + + "\x17RelatedResourcesRequest\x12\x12\n" + + "\x04type\x18\x01 \x01(\tR\x04type\x12U\n" + + "\x0fattribute_pairs\x18\x02 \x03(\v2,.proto.RelatedResourcesRequest.AttributePairR\x0eattributePairs\x122\n" + + "\x15evaluation_request_id\x18\x03 \x01(\rR\x13evaluationRequestId\x1ag\n" + + "\rAttributePair\x12)\n" + + "\x10source_attribute\x18\x01 \x01(\tR\x0fsourceAttribute\x12+\n" + + "\x11related_attribute\x18\x02 \x01(\tR\x10relatedAttribute\"N\n" + + "\x18RelatedResourcesResponse\x12\x18\n" + + "\aresults\x18\x01 \x03(\fR\aresults\x12\x18\n" + "\apartial\x18\x02 \x01(\bR\apartial\"v\n" + "\x14GetDataSourceRequest\x12\x12\n" + "\x04type\x18\x01 \x01(\tR\x04type\x12\x16\n" + @@ -275,9 +453,10 @@ const file_callback_proto_rawDesc = "" + "\x15evaluation_request_id\x18\x03 \x01(\rR\x13evaluationRequestId\"K\n" + "\x15GetDataSourceResponse\x12\x16\n" + "\x06result\x18\x01 \x01(\fR\x06result\x12\x1a\n" + - "\bdeferred\x18\x02 \x01(\bR\bdeferred2\xa6\x01\n" + + "\bdeferred\x18\x02 \x01(\bR\bdeferred2\xfb\x01\n" + "\x0fCallbackService\x12G\n" + - "\fGetResources\x12\x1a.proto.GetResourcesRequest\x1a\x1b.proto.GetResourcesResponse\x12J\n" + + "\fGetResources\x12\x1a.proto.GetResourcesRequest\x1a\x1b.proto.GetResourcesResponse\x12S\n" + + "\x10RelatedResources\x12\x1e.proto.RelatedResourcesRequest\x1a\x1f.proto.RelatedResourcesResponse\x12J\n" + "\rGetDataSource\x12\x1b.proto.GetDataSourceRequest\x1a\x1c.proto.GetDataSourceResponseB4Z2github.com/hashicorp/terraform-policy-plugin/protob\x06proto3" var ( @@ -292,23 +471,29 @@ func file_callback_proto_rawDescGZIP() []byte { return file_callback_proto_rawDescData } -var file_callback_proto_msgTypes = make([]protoimpl.MessageInfo, 4) +var file_callback_proto_msgTypes = make([]protoimpl.MessageInfo, 7) var file_callback_proto_goTypes = []any{ - (*GetResourcesRequest)(nil), // 0: proto.GetResourcesRequest - (*GetResourcesResponse)(nil), // 1: proto.GetResourcesResponse - (*GetDataSourceRequest)(nil), // 2: proto.GetDataSourceRequest - (*GetDataSourceResponse)(nil), // 3: proto.GetDataSourceResponse + (*GetResourcesRequest)(nil), // 0: proto.GetResourcesRequest + (*GetResourcesResponse)(nil), // 1: proto.GetResourcesResponse + (*RelatedResourcesRequest)(nil), // 2: proto.RelatedResourcesRequest + (*RelatedResourcesResponse)(nil), // 3: proto.RelatedResourcesResponse + (*GetDataSourceRequest)(nil), // 4: proto.GetDataSourceRequest + (*GetDataSourceResponse)(nil), // 5: proto.GetDataSourceResponse + (*RelatedResourcesRequest_AttributePair)(nil), // 6: proto.RelatedResourcesRequest.AttributePair } var file_callback_proto_depIdxs = []int32{ - 0, // 0: proto.CallbackService.GetResources:input_type -> proto.GetResourcesRequest - 2, // 1: proto.CallbackService.GetDataSource:input_type -> proto.GetDataSourceRequest - 1, // 2: proto.CallbackService.GetResources:output_type -> proto.GetResourcesResponse - 3, // 3: proto.CallbackService.GetDataSource:output_type -> proto.GetDataSourceResponse - 2, // [2:4] is the sub-list for method output_type - 0, // [0:2] is the sub-list for method input_type - 0, // [0:0] is the sub-list for extension type_name - 0, // [0:0] is the sub-list for extension extendee - 0, // [0:0] is the sub-list for field type_name + 6, // 0: proto.RelatedResourcesRequest.attribute_pairs:type_name -> proto.RelatedResourcesRequest.AttributePair + 0, // 1: proto.CallbackService.GetResources:input_type -> proto.GetResourcesRequest + 2, // 2: proto.CallbackService.RelatedResources:input_type -> proto.RelatedResourcesRequest + 4, // 3: proto.CallbackService.GetDataSource:input_type -> proto.GetDataSourceRequest + 1, // 4: proto.CallbackService.GetResources:output_type -> proto.GetResourcesResponse + 3, // 5: proto.CallbackService.RelatedResources:output_type -> proto.RelatedResourcesResponse + 5, // 6: proto.CallbackService.GetDataSource:output_type -> proto.GetDataSourceResponse + 4, // [4:7] is the sub-list for method output_type + 1, // [1:4] is the sub-list for method input_type + 1, // [1:1] is the sub-list for extension type_name + 1, // [1:1] is the sub-list for extension extendee + 0, // [0:1] is the sub-list for field type_name } func init() { file_callback_proto_init() } @@ -322,7 +507,7 @@ func file_callback_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_callback_proto_rawDesc), len(file_callback_proto_rawDesc)), NumEnums: 0, - NumMessages: 4, + NumMessages: 7, NumExtensions: 0, NumServices: 1, }, diff --git a/internal/policy/proto/callback.proto b/internal/policy/proto/callback.proto index bcfd3adcab12..a9d75241381f 100644 --- a/internal/policy/proto/callback.proto +++ b/internal/policy/proto/callback.proto @@ -9,6 +9,7 @@ option go_package = "github.com/hashicorp/terraform-policy-plugin/proto"; service CallbackService { rpc GetResources(GetResourcesRequest) returns (GetResourcesResponse); + rpc RelatedResources(RelatedResourcesRequest) returns (RelatedResourcesResponse); rpc GetDataSource(GetDataSourceRequest) returns (GetDataSourceResponse); } @@ -29,6 +30,26 @@ message GetResourcesResponse { bool partial = 2; } +message RelatedResourcesRequest { + message AttributePair { + string source_attribute = 1; + string related_attribute = 2; + } + + string type = 1; + repeated AttributePair attribute_pairs = 2; + // evaluation_request_id is the ID of the policy evaluation request that is + // making this callback request. + uint32 evaluation_request_id = 3; +} + +message RelatedResourcesResponse { + repeated bytes results = 1; + // partial means that Terraform could not conclusively determine whether the + // relationship exists for all candidates. + bool partial = 2; +} + message GetDataSourceRequest { string type = 1; bytes config = 2; diff --git a/internal/policy/proto/callback_grpc.pb.go b/internal/policy/proto/callback_grpc.pb.go index 1e945dcd8118..3f49803043a4 100644 --- a/internal/policy/proto/callback_grpc.pb.go +++ b/internal/policy/proto/callback_grpc.pb.go @@ -22,8 +22,9 @@ import ( const _ = grpc.SupportPackageIsVersion9 const ( - CallbackService_GetResources_FullMethodName = "/proto.CallbackService/GetResources" - CallbackService_GetDataSource_FullMethodName = "/proto.CallbackService/GetDataSource" + CallbackService_GetResources_FullMethodName = "/proto.CallbackService/GetResources" + CallbackService_RelatedResources_FullMethodName = "/proto.CallbackService/RelatedResources" + CallbackService_GetDataSource_FullMethodName = "/proto.CallbackService/GetDataSource" ) // CallbackServiceClient is the client API for CallbackService service. @@ -31,6 +32,7 @@ const ( // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. type CallbackServiceClient interface { GetResources(ctx context.Context, in *GetResourcesRequest, opts ...grpc.CallOption) (*GetResourcesResponse, error) + RelatedResources(ctx context.Context, in *RelatedResourcesRequest, opts ...grpc.CallOption) (*RelatedResourcesResponse, error) GetDataSource(ctx context.Context, in *GetDataSourceRequest, opts ...grpc.CallOption) (*GetDataSourceResponse, error) } @@ -52,6 +54,16 @@ func (c *callbackServiceClient) GetResources(ctx context.Context, in *GetResourc return out, nil } +func (c *callbackServiceClient) RelatedResources(ctx context.Context, in *RelatedResourcesRequest, opts ...grpc.CallOption) (*RelatedResourcesResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(RelatedResourcesResponse) + err := c.cc.Invoke(ctx, CallbackService_RelatedResources_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *callbackServiceClient) GetDataSource(ctx context.Context, in *GetDataSourceRequest, opts ...grpc.CallOption) (*GetDataSourceResponse, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(GetDataSourceResponse) @@ -67,6 +79,7 @@ func (c *callbackServiceClient) GetDataSource(ctx context.Context, in *GetDataSo // for forward compatibility. type CallbackServiceServer interface { GetResources(context.Context, *GetResourcesRequest) (*GetResourcesResponse, error) + RelatedResources(context.Context, *RelatedResourcesRequest) (*RelatedResourcesResponse, error) GetDataSource(context.Context, *GetDataSourceRequest) (*GetDataSourceResponse, error) mustEmbedUnimplementedCallbackServiceServer() } @@ -81,6 +94,9 @@ type UnimplementedCallbackServiceServer struct{} func (UnimplementedCallbackServiceServer) GetResources(context.Context, *GetResourcesRequest) (*GetResourcesResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetResources not implemented") } +func (UnimplementedCallbackServiceServer) RelatedResources(context.Context, *RelatedResourcesRequest) (*RelatedResourcesResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method RelatedResources not implemented") +} func (UnimplementedCallbackServiceServer) GetDataSource(context.Context, *GetDataSourceRequest) (*GetDataSourceResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method GetDataSource not implemented") } @@ -123,6 +139,24 @@ func _CallbackService_GetResources_Handler(srv interface{}, ctx context.Context, return interceptor(ctx, in, info, handler) } +func _CallbackService_RelatedResources_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(RelatedResourcesRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(CallbackServiceServer).RelatedResources(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: CallbackService_RelatedResources_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(CallbackServiceServer).RelatedResources(ctx, req.(*RelatedResourcesRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _CallbackService_GetDataSource_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetDataSourceRequest) if err := dec(in); err != nil { @@ -152,6 +186,10 @@ var CallbackService_ServiceDesc = grpc.ServiceDesc{ MethodName: "GetResources", Handler: _CallbackService_GetResources_Handler, }, + { + MethodName: "RelatedResources", + Handler: _CallbackService_RelatedResources_Handler, + }, { MethodName: "GetDataSource", Handler: _CallbackService_GetDataSource_Handler, diff --git a/internal/terraform/context_plan_policy_test.go b/internal/terraform/context_plan_policy_test.go index 1e9f50ebf8f0..1e98523a49e5 100644 --- a/internal/terraform/context_plan_policy_test.go +++ b/internal/terraform/context_plan_policy_test.go @@ -25,6 +25,7 @@ import ( "github.com/hashicorp/terraform/internal/lang/marks" "github.com/hashicorp/terraform/internal/plans" "github.com/hashicorp/terraform/internal/policy" + "github.com/hashicorp/terraform/internal/policy/callback" "github.com/hashicorp/terraform/internal/policy/proto" "github.com/hashicorp/terraform/internal/providers" testing_provider "github.com/hashicorp/terraform/internal/providers/testing" @@ -2191,6 +2192,438 @@ func TestContext2Plan_PolicyCallback(t *testing.T) { } } +func TestContext2Plan_PolicyCallback_RelatedResources(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + config string + pairs []callback.RelatedAttributePair + wantRelated []string + wantPartial bool + }{ + "direct traversal with pair conjunction": { + config: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "source" { + sensitive_value = "west" + random = "source" + } + + resource "test_resource" "direct" { + value = test_resource.source.id + sensitive_value = test_resource.source.sensitive_value + random = "direct" + } + + resource "test_resource" "mismatch" { + value = test_resource.source.id + sensitive_value = "east" + random = "mismatch" + } + `, + pairs: []callback.RelatedAttributePair{ + {SourceAttribute: "id", RelatedAttribute: "value"}, + {SourceAttribute: "sensitive_value", RelatedAttribute: "sensitive_value"}, + }, + wantRelated: []string{"direct"}, + wantPartial: false, + }, + "literal static equality": { + config: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "source" { + value = "literal-source" + random = "source" + } + + resource "test_resource" "literal" { + value = "literal-source" + random = "literal" + } + `, + pairs: []callback.RelatedAttributePair{ + {SourceAttribute: "value", RelatedAttribute: "value"}, + }, + wantRelated: []string{"literal"}, + wantPartial: false, + }, + "indirect local reference is partial": { + config: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + locals { + source_id = test_resource.source.id + } + + resource "test_resource" "source" { + random = "source" + } + + resource "test_resource" "indirect" { + value = local.source_id + random = "indirect" + } + `, + pairs: []callback.RelatedAttributePair{ + {SourceAttribute: "id", RelatedAttribute: "value"}, + }, + wantRelated: []string{}, + wantPartial: true, + }, + "known match with indeterminate candidate returns partial": { + config: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + locals { + source_id = test_resource.source.id + } + + resource "test_resource" "source" { + random = "source" + } + + resource "test_resource" "direct" { + value = test_resource.source.id + random = "direct" + } + + resource "test_resource" "indirect" { + value = local.source_id + random = "indirect" + } + `, + pairs: []callback.RelatedAttributePair{ + {SourceAttribute: "id", RelatedAttribute: "value"}, + }, + wantRelated: []string{"direct"}, + wantPartial: true, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mod := testModuleInline(t, map[string]string{ + "main.tf": tc.config, + "main.tfpolicy.hcl": ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = true + } + } + `, + }) + + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + + policyClient := policy.NewTestMockClient(t) + var mu sync.Mutex + callbackCalled := false + gotRelatedRandom := make([]string, 0) + gotPartial := false + + policyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.PolicyEvaluateResourceRequest_ResourceMetadata]) policy.EvaluationResponse { + if req.Target != "test_resource" { + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + if req.Attrs.Raw.IsNull() || !req.Attrs.Raw.Type().HasAttribute("random") { + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + random := req.Attrs.Raw.GetAttr("random") + if random.IsNull() || !random.IsKnown() || random.AsString() != "source" { + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + if req.Callbacks.RelatedResources == nil { + t.Errorf("RelatedResources callback was nil") + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + related, partial, err := req.Callbacks.RelatedResources(t.Context(), "test_resource", tc.pairs) + if err != nil { + t.Errorf("RelatedResources callback failed: %v", err) + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + relatedRandom := make([]string, 0, len(related)) + for _, result := range related { + if result.Type().HasAttribute("random") { + attr := result.GetAttr("random") + if attr.IsKnown() && !attr.IsNull() { + relatedRandom = append(relatedRandom, attr.AsString()) + } + } + } + sort.Strings(relatedRandom) + + mu.Lock() + callbackCalled = true + gotPartial = partial + gotRelatedRandom = relatedRandom + mu.Unlock() + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + h := &testHook{} + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + Hooks: []Hook{h}, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + _, diags = ctx.Plan(mod, states.NewState(), &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + PolicyClient: policyClient, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + var policyDiags tfdiags.Diagnostics + for _, result := range h.PolicyResults { + policyDiags = policyDiags.Append(result.Diagnostics.AsTerraformDiags()) + } + tfdiags.AssertNoDiagnostics(t, policyDiags) + + mu.Lock() + defer mu.Unlock() + if !callbackCalled { + t.Fatal("expected RelatedResources callback to be called for source resource") + } + + wantRelatedRandom := append([]string{}, tc.wantRelated...) + sort.Strings(wantRelatedRandom) + if diff := cmp.Diff(wantRelatedRandom, gotRelatedRandom); diff != "" { + t.Fatalf("unexpected related resources (-want +got):\n%s", diff) + } + if gotPartial != tc.wantPartial { + t.Fatalf("unexpected partial result: got %t, want %t", gotPartial, tc.wantPartial) + } + }) + } +} + +func TestContext2Plan_PolicyCallback_RelatedResources_KnownValuePrecedesTraversal(t *testing.T) { + t.Parallel() + + testCases := map[string]struct { + config string + configurePlanChange func(*testing_provider.MockProvider) + }{ + "ignore_changes preserves prior value": { + config: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "source" { + sensitive_value = "expected" + random = "source" + } + + resource "test_resource" "candidate" { + value = test_resource.source.sensitive_value + random = "candidate" + + lifecycle { + ignore_changes = [value] + } + } + `, + }, + "provider plan preserves prior value": { + config: ` + terraform { + required_providers { + test = { + source = "hashicorp/test" + version = "1.0.0" + } + } + } + + resource "test_resource" "source" { + sensitive_value = "expected" + random = "source" + } + + resource "test_resource" "candidate" { + value = test_resource.source.sensitive_value + random = "candidate" + } + `, + configurePlanChange: func(provider *testing_provider.MockProvider) { + provider.PlanResourceChangeFn = func(req providers.PlanResourceChangeRequest) providers.PlanResourceChangeResponse { + return providers.PlanResourceChangeResponse{PlannedState: req.PriorState} + } + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mod := testModuleInline(t, map[string]string{ + "main.tf": tc.config, + "main.tfpolicy.hcl": ` + resource_policy "test_resource" "policy_name" { + enforce { + condition = true + } + } + `, + }) + + priorState := states.BuildState(func(ss *states.SyncState) { + ss.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_resource.source"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"source-id","sensitive_value":"expected","random":"source"}`), + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + ss.SetResourceInstanceCurrent( + mustResourceInstanceAddr("test_resource.candidate"), + &states.ResourceInstanceObjectSrc{ + Status: states.ObjectReady, + AttrsJSON: []byte(`{"id":"candidate-id","value":"stale","random":"candidate"}`), + }, + mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + ) + }) + + providerAddr := addrs.NewDefaultProvider("test") + provider := testProvider("test") + if tc.configurePlanChange != nil { + tc.configurePlanChange(provider) + } + + policyClient := policy.NewTestMockClient(t) + var mu sync.Mutex + callbackCalled := false + gotRelatedRandom := make([]string, 0) + gotPartial := false + + policyClient.EvaluateFn = func(ctx context.Context, req policy.EvaluationRequest[*proto.PolicyEvaluateResourceRequest_ResourceMetadata]) policy.EvaluationResponse { + if req.Target != "test_resource" { + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + if req.Attrs.Raw.IsNull() || !req.Attrs.Raw.Type().HasAttribute("random") { + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + random := req.Attrs.Raw.GetAttr("random") + if random.IsNull() || !random.IsKnown() || random.AsString() != "source" { + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + related, partial, err := req.Callbacks.RelatedResources(t.Context(), "test_resource", []callback.RelatedAttributePair{ + {SourceAttribute: "sensitive_value", RelatedAttribute: "value"}, + }) + if err != nil { + t.Errorf("RelatedResources callback failed: %v", err) + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + relatedRandom := make([]string, 0, len(related)) + for _, result := range related { + if result.Type().HasAttribute("random") { + attr := result.GetAttr("random") + if attr.IsKnown() && !attr.IsNull() { + relatedRandom = append(relatedRandom, attr.AsString()) + } + } + } + sort.Strings(relatedRandom) + + mu.Lock() + callbackCalled = true + gotRelatedRandom = relatedRandom + gotPartial = partial + mu.Unlock() + + return policy.EvaluationResponse{Overall: policy.AllowResult} + } + + h := &testHook{} + ctx, diags := NewContext(&ContextOpts{ + Providers: map[addrs.Provider]providers.Factory{ + providerAddr: testProviderFuncFixed(provider), + }, + Hooks: []Hook{h}, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + _, diags = ctx.Plan(mod, priorState, &PlanOpts{ + Mode: plans.NormalMode, + SetVariables: testInputValuesUnset(mod.Module.Variables), + PolicyClient: policyClient, + }) + tfdiags.AssertNoDiagnostics(t, diags) + + var policyDiags tfdiags.Diagnostics + for _, result := range h.PolicyResults { + policyDiags = policyDiags.Append(result.Diagnostics.AsTerraformDiags()) + } + tfdiags.AssertNoDiagnostics(t, policyDiags) + + mu.Lock() + defer mu.Unlock() + if !callbackCalled { + t.Fatal("expected RelatedResources callback to be called for source resource") + } + if diff := cmp.Diff([]string{}, gotRelatedRandom); diff != "" { + t.Fatalf("unexpected related resources (-want +got):\n%s", diff) + } + if gotPartial { + t.Fatal("expected full result when planned literal mismatch is known") + } + }) + } +} + func TestContext2Plan_PolicyCallback_GetDataSource(t *testing.T) { t.Parallel() diff --git a/internal/terraform/node_policy_resource.go b/internal/terraform/node_policy_resource.go index f7d78b41fe80..506b89034ac0 100644 --- a/internal/terraform/node_policy_resource.go +++ b/internal/terraform/node_policy_resource.go @@ -75,8 +75,9 @@ func (n *nodeResourcePolicy) Execute(ctx EvalContext, operation walkOperation) t } callbacks := callback.Functions{ - GetResources: getResourcesForPolicyCallback(ctx, operation, provider, schema, config), - GetDataSource: getDataSourceForPolicyCallback(ctx, provider, schema), + GetResources: getResourcesForPolicyCallback(ctx, operation, provider, schema, config), + RelatedResources: relatedResourcesForPolicyCallback(ctx, operation, schema, config, n.ResourceAddr, n.After), + GetDataSource: getDataSourceForPolicyCallback(ctx, provider, schema), } result := evaluatePolicies(ctx, n.ResourceAddr, resourceConfig, n.After, n.Before, meta, callbacks) diff --git a/internal/terraform/policy.go b/internal/terraform/policy.go index b7b2a9c35299..0f04cb0e4514 100644 --- a/internal/terraform/policy.go +++ b/internal/terraform/policy.go @@ -13,6 +13,7 @@ import ( "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/trace" + "github.com/hashicorp/hcl/v2" "github.com/hashicorp/terraform/internal/addrs" "github.com/hashicorp/terraform/internal/configs" "github.com/hashicorp/terraform/internal/configs/configschema" @@ -180,6 +181,85 @@ func getDataSourceForPolicyCallback(ctx EvalContext, provider providers.Interfac } } +func relatedResourcesForPolicyCallback(ctx EvalContext, walkOperation walkOperation, schema providers.GetProviderSchemaResponse, config *configs.Config, currentAddr addrs.AbsResourceInstance, currentAttrs cty.Value) func(context.Context, string, []callback.RelatedAttributePair) ([]cty.Value, bool, error) { + return func(_ context.Context, target string, pairs []callback.RelatedAttributePair) ([]cty.Value, bool, error) { + found := make([]cty.Value, 0) + partial := false + + // Consider an example where the terraform config is: + // resource "aws_s3_bucket" "example" { + // bucket = "my-bucket" + // } + // resource "aws_s3_bucket_acl" "example" { + // bucket = aws_s3_bucket.example.id + // } + // and the related attribute pair is + // { sourceAttribute: "id", relatedAttribute: "bucket" } + config.DeepEach(func(cfg *configs.Config) { + for _, resource := range cfg.Module.ManagedResources { + if resource.Type != target { + continue + } + relatedAddr := resource.Addr().InModule(cfg.Path) + + // Skip the resource currently under evaluation, i.e aws_s3_bucket.example + if relatedAddr.Equal(currentAddr.ConfigResource()) { + continue + } + + // Deferred candidates make the overall answer incomplete. + if ctx.Deferrals().DependenciesDeferred([]addrs.ConfigResource{relatedAddr}) { + partial = true + continue + } + + relatedAttrs, _ := resource.Config.JustAttributes() + + var resourcesSeq iter.Seq[cty.Value] + if walkOperation == walkApply { + state := ctx.State() + resourceSchema := schema.SchemaForResourceAddr(relatedAddr.Resource) + // During apply, read the matching objects from state. + resourcesSeq = states.ReadEachConfigResourceInstance(state, relatedAddr, func(inst *states.ResourceInstance) (cty.Value, bool) { + if inst.Current == nil { + return cty.NilVal, false + } + decoded, err := inst.Current.Decode(resourceSchema) + if err != nil || decoded == nil { + return cty.NilVal, false + } + return decoded.Value, true + }) + } else { + // During plan, return the matching planned objects. + resourcesSeq = func(yield func(cty.Value) bool) { + for change := range plans.ReadInstancesForConfigResource(ctx.Changes(), relatedAddr) { + yield(change.After) + } + } + } + + // If the current iteration is for aws_s3_bucket_acl.example, we will + // check for the given related attribute pair to match aws_s3_bucket.example. + // We do that by checking if the related attribute (e.g. bucket) is a literal value + // or a simple traversal. If it is a literal value, we check if it matches the source attribute + // in aws_s3_bucket.example. + // If it is a traversal, we check if the traversal points to the source attribute. + for resourceValue := range resourcesSeq { + matched := relatedResourceMatchesPairs(relatedAttrs, currentAddr, resourceValue, currentAttrs, pairs) + if matched.IsWhollyKnown() && matched.True() { + resourceValue, _ = resourceValue.UnmarkDeep() + found = append(found, resourceValue) + } + partial = partial || !matched.IsWhollyKnown() + } + } + }) + + return found, partial, nil + } +} + // resourceMatchesFilter returns whether the given resource matches the given filter attributes and/or if the filter attributes are unknown for the resource. func resourceMatchesFilter(addr addrs.ConfigResource, schema *configschema.Block, filterAttrs map[string]cty.Value, resource cty.Value) (matches, unknown bool) { if resource.IsNull() { @@ -222,3 +302,94 @@ func resourceMatchesFilter(addr addrs.ConfigResource, schema *configschema.Block return true, false } + +func relatedResourceMatchesPairs(relatedAttrs hcl.Attributes, current addrs.AbsResourceInstance, relatedValue, currentValue cty.Value, pairs []callback.RelatedAttributePair) cty.Value { + // we will return unknown if we cannot determine whether the resource matches + unknown := cty.UnknownVal(cty.Bool) + + for _, pair := range pairs { + // If the current resource is null or does not have the source attribute, + // we cannot compare the literal to the current value. + if currentValue.IsNull() || !currentValue.Type().IsObjectType() || !currentValue.Type().HasAttribute(pair.SourceAttribute) { + // TODO: Is this unknown or false? + return unknown + } + + // The changeset supercedes config, so we check it first. + // If we have enough information to verify equality, we can compare the related attribute + // to the source attribute directly, without re-evaluating the related attribute expression. + if relatedValue.Type().HasAttribute(pair.RelatedAttribute) { + relatedValue := relatedValue.GetAttr(pair.RelatedAttribute) + relatedValue, _ = relatedValue.UnmarkDeep() + sourceValue := currentValue.GetAttr(pair.SourceAttribute) + sourceValue, _ = sourceValue.UnmarkDeep() + equals := relatedValue.Equals(sourceValue) + if equals.IsKnown() { + if equals.False() { // we can return early if the values do not match + return cty.False + } + + // otherwise, the values match, so we continue to the next pair + continue + } + } + + relatedAttr, ok := relatedAttrs[pair.RelatedAttribute] + if !ok { + // TODO: What if the attribute is a block attribute? + panic("related attribute not found") + } + + // We check that the related attribute expression is a plain traversal + // that refers to the source attribute in the current resource. + // Anything more complex than a plain traversal cannot be compared structurally, + // so we assume it to be unknown. + traversal, hclDiags := hcl.AbsTraversalForExpr(relatedAttr.Expr) + if hclDiags.HasErrors() { + log.Printf("[TRACE] invalid traversal: %s", hclDiags.Error()) + return unknown + } + + ref, diags := addrs.ParseRef(traversal) + if diags.HasErrors() { + log.Printf("[TRACE] invalid reference: %s", diags.Err()) + return unknown + } + + // compare the reference in the traversal to the source reference + sourceRef := &addrs.Reference{ + Subject: current.Resource, + Remaining: hcl.Traversal{hcl.TraverseAttr{Name: pair.SourceAttribute}}, + } + if !equalRef(sourceRef, ref) { + return unknown + } + } + + return cty.True +} + +func equalRef(r *addrs.Reference, other *addrs.Reference) bool { + if !addrs.Equivalent(r.Subject, other.Subject) { + return false + } + if len(r.Remaining) != len(other.Remaining) { + return false + } + for i := range r.Remaining { + ref := r.Remaining[i] + otherRef := other.Remaining[i] + refAttr, ok := ref.(hcl.TraverseAttr) + if !ok { + return false + } + otherRefAttr, ok := otherRef.(hcl.TraverseAttr) + if !ok { + return false + } + if refAttr.Name != otherRefAttr.Name { + return false + } + } + return true +} diff --git a/internal/terraform/policy_test.go b/internal/terraform/policy_test.go index 7ce361e83ebd..0574b64a5021 100644 --- a/internal/terraform/policy_test.go +++ b/internal/terraform/policy_test.go @@ -114,6 +114,181 @@ resource "test_resource" "b" { wantNames: []string{"alpha"}, wantUnknown: false, }, + { + name: "computed attribute filter matches a subset", + filter: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("a"), + }), + wantNames: []string{"alpha"}, + wantUnknown: false, + }, + { + name: "nested block filter filter matches a subset", + filter: cty.ObjectVal(map[string]cty.Value{ + "child": cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("one"), + }), + }), + wantNames: []string{"alpha"}, + wantUnknown: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var ( + got []cty.Value + gotUnknown bool + gotErr error + ) + + got, gotUnknown, gotErr = callback(t.Context(), "test_resource", tt.filter) + if gotErr != nil { + t.Fatalf("unexpected error: %v", gotErr) + } + if gotUnknown != tt.wantUnknown { + t.Fatalf("wrong unknown result\ngot: %t\nwant: %t", gotUnknown, tt.wantUnknown) + } + + gotNames := make([]string, 0, len(got)) + for _, resource := range got { + gotNames = append(gotNames, resource.GetAttr("name").AsString()) + } + sort.Strings(gotNames) + + wantNames := append([]string{}, tt.wantNames...) + sort.Strings(wantNames) + if diff := cmp.Diff(wantNames, gotNames); diff != "" { + t.Fatalf("wrong matched resources (-want +got):\n%s", diff) + } + }) + } +} +func TestGetResourcesForPolicyCallback_Plan(t *testing.T) { + providerSchema := *getProviderSchemaResponseFromProviderSchema(&providerSchema{ + ResourceTypes: map[string]*configschema.Block{ + "test_resource": { + Attributes: map[string]*configschema.Attribute{ + "id": { + Type: cty.String, + Computed: true, + }, + "name": { + Type: cty.String, + Optional: true, + }, + }, + BlockTypes: map[string]*configschema.NestedBlock{ + "child": { + Nesting: configschema.NestingSingle, + Block: configschema.Block{ + Attributes: map[string]*configschema.Attribute{ + "value": { + Type: cty.String, + Optional: true, + }, + }, + }, + }, + }, + }, + }, + }) + + config := testModuleInline(t, map[string]string{ + "main.tf": ` +resource "test_resource" "a" { + name = "alpha" + + child { + value = "one" + } +} + +resource "test_resource" "b" { + name = resource.test_resource.a.name + + child { + value = "two" + } +} +`, + }) + + resourceA := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("a"), + "name": cty.StringVal("alpha"), + "child": cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("one"), + }), + }) + resourceB := cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("b"), + "name": cty.StringVal("beta"), + "child": cty.ObjectVal(map[string]cty.Value{ + "value": cty.StringVal("two"), + }), + }) + + changes := plans.NewChanges().SyncWrapper() + changes.AppendResourceInstanceChange(&plans.ResourceInstanceChange{ + Addr: mustResourceInstanceAddr("test_resource.a"), + PrevRunAddr: mustResourceInstanceAddr("test_resource.a"), + ProviderAddr: mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + Change: plans.Change{ + Action: plans.NoOp, + Before: resourceA, + After: resourceA, + }, + }) + changes.AppendResourceInstanceChange(&plans.ResourceInstanceChange{ + Addr: mustResourceInstanceAddr("test_resource.b"), + PrevRunAddr: mustResourceInstanceAddr("test_resource.b"), + ProviderAddr: mustProviderConfig(`provider["registry.terraform.io/hashicorp/test"]`), + Change: plans.Change{ + Action: plans.NoOp, + Before: resourceB, + After: resourceB, + }, + }) + changes.Close() + + ctx := &MockEvalContext{ + StateState: states.NewState().SyncWrapper(), + ChangesChanges: changes, + DeferralsState: deferring.NewDeferred(false), + } + + callback := getResourcesForPolicyCallback(ctx, walkPlan, nil, providerSchema, config) + + tests := []struct { + name string + filter cty.Value + wantNames []string + wantUnknown bool + }{ + { + name: "null filter returns all matching resources", + filter: cty.NullVal(cty.DynamicPseudoType), + wantNames: []string{"alpha", "beta"}, + wantUnknown: false, + }, + { + name: "scalar attribute filter matches a subset", + filter: cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal("alpha"), + }), + wantNames: []string{"alpha"}, + wantUnknown: false, + }, + { + name: "computed attribute filter matches a subset", + filter: cty.ObjectVal(map[string]cty.Value{ + "id": cty.StringVal("a"), + }), + wantNames: []string{"alpha"}, + wantUnknown: false, + }, { name: "nested block filter filter matches a subset", filter: cty.ObjectVal(map[string]cty.Value{