diff --git a/.changes/unreleased/NOTES-20250325-115927.yaml b/.changes/unreleased/NOTES-20250325-115927.yaml new file mode 100644 index 000000000..3f48414f8 --- /dev/null +++ b/.changes/unreleased/NOTES-20250325-115927.yaml @@ -0,0 +1,7 @@ +kind: NOTES +body: This alpha pre-release contains testing utilities for managed resource identity, which can be used with `Terraform v1.12.0-alpha20250319`, to + assert identity data stored during apply workflows. A managed resource in a provider can read/store identity data using the `terraform-plugin-framework@v1.15.0-alpha.1` + or `terraform-plugin-sdk/v2@v2.37.0-alpha.1` Go modules. To assert identity data stored by a provider in state, use the `statecheck.ExpectIdentityValue` state check. +time: 2025-03-25T11:59:27.455519-04:00 +custom: + Issue: "468" diff --git a/.changes/unreleased/upcoming-stable/ENHANCEMENTS-20250325-121007.yaml b/.changes/unreleased/upcoming-stable/ENHANCEMENTS-20250325-121007.yaml new file mode 100644 index 000000000..c73a60ef1 --- /dev/null +++ b/.changes/unreleased/upcoming-stable/ENHANCEMENTS-20250325-121007.yaml @@ -0,0 +1,5 @@ +kind: ENHANCEMENTS +body: 'statecheck: Added `ExpectIdentityValue` state check, which asserts managed resource identity data stored in state.' +time: 2025-03-25T12:10:07.55484-04:00 +custom: + Issue: "468" diff --git a/.copywrite.hcl b/.copywrite.hcl index 301109050..514c3e4b1 100644 --- a/.copywrite.hcl +++ b/.copywrite.hcl @@ -6,7 +6,7 @@ project { header_ignore = [ # changie tooling configuration and CHANGELOG entries (prose) - ".changes/unreleased/*.yaml", + ".changes/unreleased/**", ".changie.yaml", # GitHub issue template configuration diff --git a/go.mod b/go.mod index 0c532d06f..b240f9d94 100644 --- a/go.mod +++ b/go.mod @@ -14,8 +14,8 @@ require ( github.com/hashicorp/hcl/v2 v2.23.0 github.com/hashicorp/logutils v1.0.0 github.com/hashicorp/terraform-exec v0.22.0 - github.com/hashicorp/terraform-json v0.24.0 - github.com/hashicorp/terraform-plugin-go v0.26.0 + github.com/hashicorp/terraform-json v0.24.1-0.20250314103308-f86d5e36f4ab + github.com/hashicorp/terraform-plugin-go v0.27.0-alpha.1 github.com/hashicorp/terraform-plugin-log v0.9.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 github.com/mitchellh/go-testing-interface v1.14.1 @@ -34,7 +34,7 @@ require ( github.com/hashicorp/go-checkpoint v0.5.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect - github.com/hashicorp/go-plugin v1.6.2 // indirect + github.com/hashicorp/go-plugin v1.6.3 // indirect github.com/hashicorp/go-retryablehttp v0.7.7 // indirect github.com/hashicorp/terraform-registry-address v0.2.4 // indirect github.com/hashicorp/terraform-svchost v0.1.1 // indirect @@ -56,7 +56,7 @@ require ( golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 // indirect - google.golang.org/grpc v1.69.4 // indirect - google.golang.org/protobuf v1.36.3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect + google.golang.org/grpc v1.71.0 // indirect + google.golang.org/protobuf v1.36.5 // indirect ) diff --git a/go.sum b/go.sum index f56d072f5..d9986ce44 100644 --- a/go.sum +++ b/go.sum @@ -61,8 +61,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1 github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= -github.com/hashicorp/go-plugin v1.6.2 h1:zdGAEd0V1lCaU0u+MxWQhtSDQmahpkwOun8U8EiRVog= -github.com/hashicorp/go-plugin v1.6.2/go.mod h1:CkgLQ5CZqNmdL9U9JzM532t8ZiYQ35+pj3b1FD37R0Q= +github.com/hashicorp/go-plugin v1.6.3 h1:xgHB+ZUSYeuJi96WtxEjzi23uh7YQpznjGh0U0UUrwg= +github.com/hashicorp/go-plugin v1.6.3/go.mod h1:MRobyh+Wc/nYy1V4KAXUiYfzxoYhs7V1mlH1Z7iY2h0= github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= @@ -78,10 +78,10 @@ github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= github.com/hashicorp/terraform-exec v0.22.0 h1:G5+4Sz6jYZfRYUCg6eQgDsqTzkNXV+fP8l+uRmZHj64= github.com/hashicorp/terraform-exec v0.22.0/go.mod h1:bjVbsncaeh8jVdhttWYZuBGj21FcYw6Ia/XfHcNO7lQ= -github.com/hashicorp/terraform-json v0.24.0 h1:rUiyF+x1kYawXeRth6fKFm/MdfBS6+lW4NbeATsYz8Q= -github.com/hashicorp/terraform-json v0.24.0/go.mod h1:Nfj5ubo9xbu9uiAoZVBsNOjvNKB66Oyrvtit74kC7ow= -github.com/hashicorp/terraform-plugin-go v0.26.0 h1:cuIzCv4qwigug3OS7iKhpGAbZTiypAfFQmw8aE65O2M= -github.com/hashicorp/terraform-plugin-go v0.26.0/go.mod h1:+CXjuLDiFgqR+GcrM5a2E2Kal5t5q2jb0E3D57tTdNY= +github.com/hashicorp/terraform-json v0.24.1-0.20250314103308-f86d5e36f4ab h1:5Qpuprk76zkVEdTCtfoPjUc+1AeUxlgkF6sWTr7qLDs= +github.com/hashicorp/terraform-json v0.24.1-0.20250314103308-f86d5e36f4ab/go.mod h1:sMKS8fiRDX4rVlR6EJUMudg1WcanxCMoWwTLkgZP/vc= +github.com/hashicorp/terraform-plugin-go v0.27.0-alpha.1 h1:/IZFNUEafGnJGXRe2iNQQ+vtzEw/5qiD+gOxkFrNbi4= +github.com/hashicorp/terraform-plugin-go v0.27.0-alpha.1/go.mod h1:Tf2HngbyKvovAlGXgBOVGm3EDvbNaN/StUaTXwrej4o= github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= github.com/hashicorp/terraform-plugin-sdk/v2 v2.36.1 h1:WNMsTLkZf/3ydlgsuXePa3jvZFwAJhruxTxP/c1Viuw= @@ -150,16 +150,18 @@ github.com/zclconf/go-cty v1.16.2 h1:LAJSwc3v81IRBZyUVQDUdZ7hs3SYs9jv0eZJDWHD/70 github.com/zclconf/go-cty v1.16.2/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940 h1:4r45xpDWB6ZMSMNJFMOjqrGHynW3DIBuR2H9j0ug+Mo= github.com/zclconf/go-cty-debug v0.0.0-20240509010212-0d6042c53940/go.mod h1:CmBdvvj3nqzfzJ6nTCIwDTPZ56aVGvDrmztiO5g3qrM= -go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY= -go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE= -go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE= -go.opentelemetry.io/otel/metric v1.31.0/go.mod h1:C3dEloVbLuYoX41KpmAhOqNriGbA+qqH6PQ5E5mUfnY= -go.opentelemetry.io/otel/sdk v1.31.0 h1:xLY3abVHYZ5HSfOg3l2E5LUj2Cwva5Y7yGxnSW9H5Gk= -go.opentelemetry.io/otel/sdk v1.31.0/go.mod h1:TfRbMdhvxIIr/B2N2LQW2S5v9m3gOQ/08KsbbO5BPT0= -go.opentelemetry.io/otel/sdk/metric v1.31.0 h1:i9hxxLJF/9kkvfHppyLL55aW7iIJz4JjxTeYusH7zMc= -go.opentelemetry.io/otel/sdk/metric v1.31.0/go.mod h1:CRInTMVvNhUKgSAMbKyTMxqOBC0zgyxzW55lZzX43Y8= -go.opentelemetry.io/otel/trace v1.31.0 h1:ffjsj1aRouKewfr85U2aGagJ46+MvodynlQ1HYdmJys= -go.opentelemetry.io/otel/trace v1.31.0/go.mod h1:TXZkRk7SM2ZQLtR6eoAWQFIHPvzQ06FJAsO1tJg480A= +go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA= +go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A= +go.opentelemetry.io/otel v1.34.0 h1:zRLXxLCgL1WyKsPVrgbSdMN4c0FMkDAskSTQP+0hdUY= +go.opentelemetry.io/otel v1.34.0/go.mod h1:OWFPOQ+h4G8xpyjgqo4SxJYdDQ/qmRH+wivy7zzx9oI= +go.opentelemetry.io/otel/metric v1.34.0 h1:+eTR3U0MyfWjRDhmFMxe2SsW64QrZ84AOhvqS7Y+PoQ= +go.opentelemetry.io/otel/metric v1.34.0/go.mod h1:CEDrp0fy2D0MvkXE+dPV7cMi8tWZwX3dmaIhwPOaqHE= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.34.0 h1:+ouXS2V8Rd4hp4580a8q23bg0azF2nI8cqLYnC8mh/k= +go.opentelemetry.io/otel/trace v1.34.0/go.mod h1:Svm7lSjQD7kG7KJ/MUHPVXSDGz2OX4h0M2jHBhmSfRE= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= @@ -212,14 +214,14 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53 h1:X58yt85/IXCx0Y3ZwN6sEIKZzQtDEYaBWrDvErdXrRE= -google.golang.org/genproto/googleapis/rpc v0.0.0-20241015192408-796eee8c2d53/go.mod h1:GX3210XPVPUjJbTUbvwI8f2IpZDMZuPJWDzDuebbviI= -google.golang.org/grpc v1.69.4 h1:MF5TftSMkd8GLw/m0KM6V8CMOCY6NZ1NQDPGFgbTt4A= -google.golang.org/grpc v1.69.4/go.mod h1:vyjdE6jLBI76dgpDojsFGNaHlxdjXN9ghpnd2o7JGZ4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.36.3 h1:82DV7MYdb8anAVi3qge1wSnMDrnKK7ebr+I0hHRN1BU= -google.golang.org/protobuf v1.36.3/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= +google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM= +google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/testing/testprovider/resource.go b/internal/testing/testprovider/resource.go index 8421e54d1..8969f5ca9 100644 --- a/internal/testing/testprovider/resource.go +++ b/internal/testing/testprovider/resource.go @@ -21,6 +21,7 @@ type Resource struct { PlanChangeFunc func(context.Context, resource.PlanChangeRequest, *resource.PlanChangeResponse) ReadResponse *resource.ReadResponse + IdentitySchemaResponse *resource.IdentitySchemaResponse SchemaResponse *resource.SchemaResponse UpdateResponse *resource.UpdateResponse UpgradeStateResponse *resource.UpgradeStateResponse @@ -31,6 +32,7 @@ func (r Resource) Create(ctx context.Context, req resource.CreateRequest, resp * if r.CreateResponse != nil { resp.Diagnostics = r.CreateResponse.Diagnostics resp.NewState = r.CreateResponse.NewState + resp.NewIdentity = r.CreateResponse.NewIdentity } } @@ -44,6 +46,7 @@ func (r Resource) ImportState(ctx context.Context, req resource.ImportStateReque if r.ImportStateResponse != nil { resp.Diagnostics = r.ImportStateResponse.Diagnostics resp.State = r.ImportStateResponse.State + resp.Identity = r.ImportStateResponse.Identity } } @@ -57,6 +60,14 @@ func (r Resource) Read(ctx context.Context, req resource.ReadRequest, resp *reso if r.ReadResponse != nil { resp.Diagnostics = r.ReadResponse.Diagnostics resp.NewState = r.ReadResponse.NewState + resp.NewIdentity = r.ReadResponse.NewIdentity + } +} + +func (r Resource) IdentitySchema(ctx context.Context, req resource.IdentitySchemaRequest, resp *resource.IdentitySchemaResponse) { + if r.IdentitySchemaResponse != nil { + resp.Diagnostics = r.IdentitySchemaResponse.Diagnostics + resp.Schema = r.IdentitySchemaResponse.Schema } } @@ -71,6 +82,7 @@ func (r Resource) Update(ctx context.Context, req resource.UpdateRequest, resp * if r.UpdateResponse != nil { resp.Diagnostics = r.UpdateResponse.Diagnostics resp.NewState = r.UpdateResponse.NewState + resp.NewIdentity = r.UpdateResponse.NewIdentity } } diff --git a/internal/testing/testsdk/providerserver/providerserver.go b/internal/testing/testsdk/providerserver/providerserver.go index 3c763914c..b50a319d8 100644 --- a/internal/testing/testsdk/providerserver/providerserver.go +++ b/internal/testing/testsdk/providerserver/providerserver.go @@ -5,6 +5,7 @@ package providerserver import ( "context" + "errors" "fmt" "github.com/hashicorp/terraform-plugin-go/tfprotov6" @@ -49,11 +50,16 @@ func NewProviderServerWithError(p provider.Provider, err error) func() (tfprotov // By default, the following data is copied automatically: // // - ApplyResourceChange (create): req.Config -> resp.NewState +// - ApplyResourceChange (create): req.PlannedIdentity -> resp.NewIdentity // - ApplyResourceChange (delete): req.PlannedState -> resp.NewState // - ApplyResourceChange (update): req.PlannedState -> resp.NewState +// - ApplyResourceChange (update): req.PlannedIdentity -> resp.NewIdentity // - PlanResourceChange: req.ProposedNewState -> resp.PlannedState +// - PlanResourceChange: req.PriorIdentity -> resp.PlannedIdentity +// - ImportResourceState: req.Identity -> resp.ImportedResources[0].Identity // - ReadDataSource: req.Config -> resp.State // - ReadResource: req.CurrentState -> resp.NewState +// - ReadResource: req.CurrentIdentity -> resp.NewIdentity type ProviderServer struct { Provider provider.Provider } @@ -135,12 +141,40 @@ func (s ProviderServer) ApplyResourceChange(ctx context.Context, req *tfprotov6. return resp, nil } + // Copy over identity if it's supported + identitySchemaReq := resource.IdentitySchemaRequest{} + identitySchemaResp := &resource.IdentitySchemaResponse{} + + r.IdentitySchema(ctx, identitySchemaReq, identitySchemaResp) + + resp.Diagnostics = identitySchemaResp.Diagnostics + + if len(resp.Diagnostics) > 0 { + return resp, nil + } + + var plannedIdentity *tftypes.Value + if identitySchemaResp.Schema != nil && req.PlannedIdentity != nil { + plannedIdentityVal, diag := IdentityDynamicValueToValue(identitySchemaResp.Schema, req.PlannedIdentity.IdentityData) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + plannedIdentity = &plannedIdentityVal + } + + var newIdentity *tftypes.Value if priorState.IsNull() { createReq := resource.CreateRequest{ - Config: config, + Config: config, + PlannedIdentity: plannedIdentity, } createResp := &resource.CreateResponse{ - NewState: config.Copy(), + NewState: config.Copy(), + NewIdentity: plannedIdentity, } r.Create(ctx, createReq, createResp) @@ -160,6 +194,7 @@ func (s ProviderServer) ApplyResourceChange(ctx context.Context, req *tfprotov6. } resp.NewState = newState + newIdentity = createResp.NewIdentity } else if plannedState.IsNull() { deleteReq := resource.DeleteRequest{ PriorState: priorState, @@ -177,12 +212,14 @@ func (s ProviderServer) ApplyResourceChange(ctx context.Context, req *tfprotov6. resp.NewState = req.PlannedState } else { updateReq := resource.UpdateRequest{ - Config: config, - PlannedState: plannedState, - PriorState: priorState, + Config: config, + PlannedState: plannedState, + PlannedIdentity: plannedIdentity, + PriorState: priorState, } updateResp := &resource.UpdateResponse{ - NewState: plannedState.Copy(), + NewState: plannedState.Copy(), + NewIdentity: plannedIdentity, } r.Update(ctx, updateReq, updateResp) @@ -202,6 +239,21 @@ func (s ProviderServer) ApplyResourceChange(ctx context.Context, req *tfprotov6. } resp.NewState = newState + newIdentity = updateResp.NewIdentity + } + + if newIdentity != nil { + newIdentityVal, diag := IdentityValuetoDynamicValue(identitySchemaResp.Schema, *newIdentity) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + resp.NewIdentity = &tfprotov6.ResourceIdentityData{ + IdentityData: newIdentityVal, + } } return resp, nil @@ -286,6 +338,27 @@ func (s ProviderServer) GetProviderSchema(ctx context.Context, req *tfprotov6.Ge return resp, nil } +func (s ProviderServer) GetResourceIdentitySchemas(ctx context.Context, req *tfprotov6.GetResourceIdentitySchemasRequest) (*tfprotov6.GetResourceIdentitySchemasResponse, error) { + resp := &tfprotov6.GetResourceIdentitySchemasResponse{ + IdentitySchemas: map[string]*tfprotov6.ResourceIdentitySchema{}, + } + + for typeName, r := range s.Provider.ResourcesMap() { + identitySchemaReq := resource.IdentitySchemaRequest{} + identitySchemaResp := &resource.IdentitySchemaResponse{} + + r.IdentitySchema(ctx, identitySchemaReq, identitySchemaResp) + + resp.Diagnostics = append(resp.Diagnostics, identitySchemaResp.Diagnostics...) + + if identitySchemaResp.Schema != nil { + resp.IdentitySchemas[typeName] = identitySchemaResp.Schema + } + } + + return resp, nil +} + func (s ProviderServer) ImportResourceState(ctx context.Context, req *tfprotov6.ImportResourceStateRequest) (*tfprotov6.ImportResourceStateResponse, error) { resp := &tfprotov6.ImportResourceStateResponse{} @@ -313,6 +386,31 @@ func (s ProviderServer) ImportResourceState(ctx context.Context, req *tfprotov6. } importResp := &resource.ImportStateResponse{} + // Copy over identity if it's supported + identitySchemaReq := resource.IdentitySchemaRequest{} + identitySchemaResp := &resource.IdentitySchemaResponse{} + + r.IdentitySchema(ctx, identitySchemaReq, identitySchemaResp) + + resp.Diagnostics = identitySchemaResp.Diagnostics + + if len(resp.Diagnostics) > 0 { + return resp, nil + } + + if identitySchemaResp.Schema != nil && req.Identity != nil { + identity, diag := IdentityDynamicValueToValue(identitySchemaResp.Schema, req.Identity.IdentityData) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + importReq.Identity = &identity + importResp.Identity = &identity + } + r.ImportState(ctx, importReq, importResp) resp.Diagnostics = importResp.Diagnostics @@ -347,6 +445,21 @@ func (s ProviderServer) ImportResourceState(ctx context.Context, req *tfprotov6. }, } + if importResp.Identity != nil { + identity, diag := IdentityValuetoDynamicValue(identitySchemaResp.Schema, *importResp.Identity) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + // There is only one imported resource, so this should always be safe + resp.ImportedResources[0].Identity = &tfprotov6.ResourceIdentityData{ + IdentityData: identity, + } + } + return resp, nil } @@ -456,6 +569,31 @@ func (s ProviderServer) PlanResourceChange(ctx context.Context, req *tfprotov6.P PlannedState: proposedNewState.Copy(), } + // Copy over identity if it's supported + identitySchemaReq := resource.IdentitySchemaRequest{} + identitySchemaResp := &resource.IdentitySchemaResponse{} + + r.IdentitySchema(ctx, identitySchemaReq, identitySchemaResp) + + resp.Diagnostics = identitySchemaResp.Diagnostics + + if len(resp.Diagnostics) > 0 { + return resp, nil + } + + if identitySchemaResp.Schema != nil && req.PriorIdentity != nil { + priorIdentity, diag := IdentityDynamicValueToValue(identitySchemaResp.Schema, req.PriorIdentity.IdentityData) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + planReq.PriorIdentity = &priorIdentity + planResp.PlannedIdentity = &priorIdentity + } + r.PlanChange(ctx, planReq, planResp) resp.Diagnostics = planResp.Diagnostics @@ -474,6 +612,20 @@ func (s ProviderServer) PlanResourceChange(ctx context.Context, req *tfprotov6.P return resp, nil } + if planResp.PlannedIdentity != nil { + plannedIdentity, diag := IdentityValuetoDynamicValue(identitySchemaResp.Schema, *planResp.PlannedIdentity) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + resp.PlannedIdentity = &tfprotov6.ResourceIdentityData{ + IdentityData: plannedIdentity, + } + } + resp.PlannedState = plannedState return resp, nil @@ -574,6 +726,31 @@ func (s ProviderServer) ReadResource(ctx context.Context, req *tfprotov6.ReadRes NewState: currentState.Copy(), } + // Copy over identity if it's supported + identitySchemaReq := resource.IdentitySchemaRequest{} + identitySchemaResp := &resource.IdentitySchemaResponse{} + + r.IdentitySchema(ctx, identitySchemaReq, identitySchemaResp) + + resp.Diagnostics = identitySchemaResp.Diagnostics + + if len(resp.Diagnostics) > 0 { + return resp, nil + } + + if identitySchemaResp.Schema != nil && req.CurrentIdentity != nil { + currentIdentity, diag := IdentityDynamicValueToValue(identitySchemaResp.Schema, req.CurrentIdentity.IdentityData) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + readReq.CurrentIdentity = ¤tIdentity + readResp.NewIdentity = ¤tIdentity + } + r.Read(ctx, readReq, readResp) resp.Diagnostics = readResp.Diagnostics @@ -592,6 +769,20 @@ func (s ProviderServer) ReadResource(ctx context.Context, req *tfprotov6.ReadRes resp.NewState = newState + if readResp.NewIdentity != nil { + newIdentity, diag := IdentityValuetoDynamicValue(identitySchemaResp.Schema, *readResp.NewIdentity) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + resp.NewIdentity = &tfprotov6.ResourceIdentityData{ + IdentityData: newIdentity, + } + } + return resp, nil } @@ -698,6 +889,11 @@ func (s ProviderServer) UpgradeResourceState(ctx context.Context, req *tfprotov6 return resp, nil } +func (s ProviderServer) UpgradeResourceIdentity(context.Context, *tfprotov6.UpgradeResourceIdentityRequest) (*tfprotov6.UpgradeResourceIdentityResponse, error) { + // TODO: Implement + return nil, errors.New("UpgradeResourceIdentity is not currently implemented in testprovider") +} + func (s ProviderServer) ValidateDataResourceConfig(ctx context.Context, req *tfprotov6.ValidateDataResourceConfigRequest) (*tfprotov6.ValidateDataResourceConfigResponse, error) { resp := &tfprotov6.ValidateDataResourceConfigResponse{} diff --git a/internal/testing/testsdk/providerserver/tftypes.go b/internal/testing/testsdk/providerserver/tftypes.go index 4b9e07ec7..e34541d35 100644 --- a/internal/testing/testsdk/providerserver/tftypes.go +++ b/internal/testing/testsdk/providerserver/tftypes.go @@ -63,3 +63,98 @@ func ValuetoDynamicValue(schema *tfprotov6.Schema, value tftypes.Value) (*tfprot return &dynamicValue, nil } + +func IdentityDynamicValueToValue(schema *tfprotov6.ResourceIdentitySchema, dynamicValue *tfprotov6.DynamicValue) (tftypes.Value, *tfprotov6.Diagnostic) { + if schema == nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert DynamicValue", + Detail: "Converting the DynamicValue to Value returned an unexpected error: missing identity schema", + } + + return tftypes.NewValue(tftypes.Object{}, nil), diag + } + + if dynamicValue == nil { + return tftypes.NewValue(getIdentitySchemaValueType(schema), nil), nil + } + + value, err := dynamicValue.Unmarshal(getIdentitySchemaValueType(schema)) + + if err != nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert DynamicValue", + Detail: "Converting the DynamicValue to Value returned an unexpected error: " + err.Error(), + } + + return value, diag + } + + return value, nil +} + +func IdentityValuetoDynamicValue(schema *tfprotov6.ResourceIdentitySchema, value tftypes.Value) (*tfprotov6.DynamicValue, *tfprotov6.Diagnostic) { + if schema == nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert Value", + Detail: "Converting the Value to DynamicValue returned an unexpected error: missing identity schema", + } + + return nil, diag + } + + dynamicValue, err := tfprotov6.NewDynamicValue(getIdentitySchemaValueType(schema), value) + + if err != nil { + diag := &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Unable to Convert Value", + Detail: "Converting the Value to DynamicValue returned an unexpected error: " + err.Error(), + } + + return &dynamicValue, diag + } + + return &dynamicValue, nil +} + +// TODO: This should be replaced by the `ValueType` method from plugin-go: +// https://github.com/hashicorp/terraform-plugin-go/pull/497 +func getIdentitySchemaValueType(schema *tfprotov6.ResourceIdentitySchema) tftypes.Type { + if schema == nil || schema.IdentityAttributes == nil { + return tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{}, + } + } + attributeTypes := map[string]tftypes.Type{} + + for _, attribute := range schema.IdentityAttributes { + if attribute == nil { + continue + } + + attributeType := getIdentityAttributeValueType(attribute) + + if attributeType == nil { + continue + } + + attributeTypes[attribute.Name] = attributeType + } + + return tftypes.Object{ + AttributeTypes: attributeTypes, + } +} + +// TODO: This should be replaced by the `ValueType` method from plugin-go: +// https://github.com/hashicorp/terraform-plugin-go/pull/497 +func getIdentityAttributeValueType(attr *tfprotov6.ResourceIdentitySchemaAttribute) tftypes.Type { + if attr == nil { + return nil + } + + return attr.Type +} diff --git a/internal/testing/testsdk/resource/resource.go b/internal/testing/testsdk/resource/resource.go index 5fea34468..3fb3703ae 100644 --- a/internal/testing/testsdk/resource/resource.go +++ b/internal/testing/testsdk/resource/resource.go @@ -16,6 +16,7 @@ type Resource interface { ImportState(context.Context, ImportStateRequest, *ImportStateResponse) PlanChange(context.Context, PlanChangeRequest, *PlanChangeResponse) Read(context.Context, ReadRequest, *ReadResponse) + IdentitySchema(context.Context, IdentitySchemaRequest, *IdentitySchemaResponse) Schema(context.Context, SchemaRequest, *SchemaResponse) Update(context.Context, UpdateRequest, *UpdateResponse) UpgradeState(context.Context, UpgradeStateRequest, *UpgradeStateResponse) @@ -23,12 +24,14 @@ type Resource interface { } type CreateRequest struct { - Config tftypes.Value + Config tftypes.Value + PlannedIdentity *tftypes.Value } type CreateResponse struct { Diagnostics []*tfprotov6.Diagnostic NewState tftypes.Value + NewIdentity *tftypes.Value } type DeleteRequest struct { @@ -39,18 +42,28 @@ type DeleteResponse struct { Diagnostics []*tfprotov6.Diagnostic } +type IdentitySchemaRequest struct{} + +type IdentitySchemaResponse struct { + Diagnostics []*tfprotov6.Diagnostic + Schema *tfprotov6.ResourceIdentitySchema +} + type ImportStateRequest struct { - ID string + ID string + Identity *tftypes.Value } type ImportStateResponse struct { Diagnostics []*tfprotov6.Diagnostic State tftypes.Value + Identity *tftypes.Value } type PlanChangeRequest struct { Config tftypes.Value PriorState tftypes.Value + PriorIdentity *tftypes.Value ProposedNewState tftypes.Value } @@ -58,16 +71,19 @@ type PlanChangeResponse struct { Deferred *tfprotov6.Deferred Diagnostics []*tfprotov6.Diagnostic PlannedState tftypes.Value + PlannedIdentity *tftypes.Value RequiresReplace []*tftypes.AttributePath } type ReadRequest struct { - CurrentState tftypes.Value + CurrentState tftypes.Value + CurrentIdentity *tftypes.Value } type ReadResponse struct { Diagnostics []*tfprotov6.Diagnostic NewState tftypes.Value + NewIdentity *tftypes.Value } type SchemaRequest struct{} @@ -78,14 +94,16 @@ type SchemaResponse struct { } type UpdateRequest struct { - Config tftypes.Value - PlannedState tftypes.Value - PriorState tftypes.Value + Config tftypes.Value + PlannedState tftypes.Value + PlannedIdentity *tftypes.Value + PriorState tftypes.Value } type UpdateResponse struct { Diagnostics []*tfprotov6.Diagnostic NewState tftypes.Value + NewIdentity *tftypes.Value } type UpgradeStateRequest struct { diff --git a/statecheck/expect_identity_value.go b/statecheck/expect_identity_value.go new file mode 100644 index 000000000..22da58ea8 --- /dev/null +++ b/statecheck/expect_identity_value.go @@ -0,0 +1,91 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck + +import ( + "context" + "fmt" + + tfjson "github.com/hashicorp/terraform-json" + + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" +) + +var _ StateCheck = expectIdentityValue{} + +type expectIdentityValue struct { + resourceAddress string + attributePath tfjsonpath.Path + identityValue knownvalue.Check +} + +// CheckState implements the state check logic. +func (e expectIdentityValue) CheckState(ctx context.Context, req CheckStateRequest, resp *CheckStateResponse) { + var resource *tfjson.StateResource + + if req.State == nil { + resp.Error = fmt.Errorf("state is nil") + + return + } + + if req.State.Values == nil { + resp.Error = fmt.Errorf("state does not contain any state values") + + return + } + + if req.State.Values.RootModule == nil { + resp.Error = fmt.Errorf("state does not contain a root module") + + return + } + + for _, r := range req.State.Values.RootModule.Resources { + if e.resourceAddress == r.Address { + resource = r + + break + } + } + + if resource == nil { + resp.Error = fmt.Errorf("%s - Resource not found in state", e.resourceAddress) + + return + } + + if resource.IdentitySchemaVersion == nil || len(resource.IdentityValues) == 0 { + resp.Error = fmt.Errorf("%s - Identity not found in state. Either the resource does not support identity or the Terraform version running the test does not support identity. (must be v1.12+)", e.resourceAddress) + + return + } + + result, err := tfjsonpath.Traverse(resource.IdentityValues, e.attributePath) + + if err != nil { + resp.Error = err + + return + } + + if err := e.identityValue.CheckValue(result); err != nil { + resp.Error = fmt.Errorf("error checking identity value for attribute at path: %s.%s, err: %s", e.resourceAddress, e.attributePath.String(), err) + + return + } +} + +// ExpectIdentityValue returns a state check that asserts that the specified identity attribute at the given resource +// matches a known value. This state check can only be used with managed resources that support resource identity. +// +// Resource identity is only supported in Terraform v1.12+ +func ExpectIdentityValue(resourceAddress string, attributePath tfjsonpath.Path, identityValue knownvalue.Check) StateCheck { + return expectIdentityValue{ + resourceAddress: resourceAddress, + attributePath: attributePath, + identityValue: identityValue, + } +} diff --git a/statecheck/expect_identity_value_example_test.go b/statecheck/expect_identity_value_example_test.go new file mode 100644 index 000000000..38aa506f2 --- /dev/null +++ b/statecheck/expect_identity_value_example_test.go @@ -0,0 +1,40 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func ExampleExpectIdentityValue() { + // A typical test would accept *testing.T as a function parameter, for instance `func TestSomething(t *testing.T) { ... }`. + t := &testing.T{} + t.Parallel() + + resource.Test(t, resource.TestCase{ + // Resource identity support is only available in Terraform v1.12+ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + // Provider definition omitted. Assuming "test_resource" has an identity schema with an "id" string attribute + Steps: []resource.TestStep{ + { + Config: `resource "test_resource" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "test_resource.one", + tfjsonpath.New("id"), + knownvalue.StringExact("id-123"), + ), + }, + }, + }, + }) +} diff --git a/statecheck/expect_identity_value_test.go b/statecheck/expect_identity_value_test.go new file mode 100644 index 000000000..71c56c072 --- /dev/null +++ b/statecheck/expect_identity_value_test.go @@ -0,0 +1,440 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package statecheck_test + +import ( + "regexp" + "testing" + + "github.com/hashicorp/go-version" + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/providerserver" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/teststep" + "github.com/hashicorp/terraform-plugin-testing/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/statecheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestExpectIdentityValue_CheckState_ResourceNotFound(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.two", + tfjsonpath.New("id"), + knownvalue.StringExact("id-123"), + ), + }, + ExpectError: regexp.MustCompile("examplecloud_thing.two - Resource not found in state"), + }, + }, + }) +} + +func TestExpectIdentityValue_CheckState_No_Terraform_Identity_Support(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories + tfversion.SkipAbove(tfversion.Version1_11_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + // Resource support identity, but the Terraform versions running will not. + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("id"), + knownvalue.StringExact("id-123"), + ), + }, + ExpectError: regexp.MustCompile(`examplecloud_thing.one - Identity not found in state. Either the resource ` + + `does not support identity or the Terraform version running the test does not support identity. \(must be v1.12\+\)`, + ), + }, + }, + }) +} + +func TestExpectIdentityValue_CheckState_No_Identity(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + // Resource does not support identity + "examplecloud": examplecloudProviderNoIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("id"), + knownvalue.StringExact("id-123"), + ), + }, + ExpectError: regexp.MustCompile(`examplecloud_thing.one - Identity not found in state. Either the resource ` + + `does not support identity or the Terraform version running the test does not support identity. \(must be v1.12\+\)`, + ), + }, + }, + }) +} + +func TestExpectIdentityValue_CheckState_String(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + // TODO: There is currently a bug in Terraform v1.12.0-alpha20250319 that causes a panic + // when refreshing a resource that has an identity stored via protocol v6. + // + // We can remove this skip once the bug fix is merged/released: + // - https://github.com/hashicorp/terraform/pull/36756 + tfversion.SkipIf(version.Must(version.NewVersion("1.12.0-alpha20250319"))), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("id"), + knownvalue.StringExact("id-123")), + }, + }, + }, + }) +} + +func TestExpectIdentityValue_CheckState_String_KnownValueWrongType(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("id"), + knownvalue.Bool(true)), + }, + ExpectError: regexp.MustCompile("expected bool value for Bool check, got: string"), + }, + }, + }) +} + +func TestExpectIdentityValue_CheckState_String_KnownValueWrongValue(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("id"), + knownvalue.StringExact("321-id")), + }, + ExpectError: regexp.MustCompile("expected value 321-id for StringExact check, got: id-123"), + }, + }, + }) +} + +func TestExpectIdentityValue_CheckState_List(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + // TODO: There is currently a bug in Terraform v1.12.0-alpha20250319 that causes a panic + // when refreshing a resource that has an identity stored via protocol v6. + // + // We can remove this skip once the bug fix is merged/released: + // - https://github.com/hashicorp/terraform/pull/36756 + tfversion.SkipIf(version.Must(version.NewVersion("1.12.0-alpha20250319"))), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers").AtSliceIndex(0), + knownvalue.Int64Exact(1), + ), + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers").AtSliceIndex(1), + knownvalue.Int64Exact(2), + ), + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers").AtSliceIndex(2), + knownvalue.Int64Exact(3), + ), + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers").AtSliceIndex(3), + knownvalue.Int64Exact(4), + ), + }, + }, + }, + }) +} + +func TestExpectIdentityValue_CheckState_List_KnownValueWrongType(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {} + `, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers"), + knownvalue.MapExact(map[string]knownvalue.Check{}), + ), + }, + ExpectError: regexp.MustCompile(`expected map\[string\]any value for MapExact check, got: \[\]interface {}`), + }, + }, + }) +} + +func TestExpectIdentityValue_CheckState_List_KnownValueWrongValue(t *testing.T) { + t.Parallel() + + r.Test(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": examplecloudProviderWithResourceIdentity(), + }, + Steps: []r.TestStep{ + { + Config: `resource "examplecloud_thing" "one" {}`, + ConfigStateChecks: []statecheck.StateCheck{ + statecheck.ExpectIdentityValue( + "examplecloud_thing.one", + tfjsonpath.New("list_of_numbers"), + knownvalue.ListExact([]knownvalue.Check{ + knownvalue.Int64Exact(4), + knownvalue.Int64Exact(3), + knownvalue.Int64Exact(2), + knownvalue.Int64Exact(1), + }), + ), + }, + ExpectError: regexp.MustCompile(`list element index 0: expected value 4 for Int64Exact check, got: 1`), + }, + }, + }) +} + +func examplecloudProviderWithResourceIdentity() func() (tfprotov6.ProviderServer, error) { + return providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_thing": { + CreateResponse: &resource.CreateResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test value"), + }, + ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "id-123"), + "list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), + }, + )), + }, + ReadResponse: &resource.ReadResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test value"), + }, + ), + NewIdentity: teststep.Pointer(tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "id": tftypes.String, + "list_of_numbers": tftypes.List{ElementType: tftypes.Number}, + }, + }, + map[string]tftypes.Value{ + "id": tftypes.NewValue(tftypes.String, "id-123"), + "list_of_numbers": tftypes.NewValue( + tftypes.List{ElementType: tftypes.Number}, + []tftypes.Value{ + tftypes.NewValue(tftypes.Number, 1), + tftypes.NewValue(tftypes.Number, 2), + tftypes.NewValue(tftypes.Number, 3), + tftypes.NewValue(tftypes.Number, 4), + }, + ), + }, + )), + }, + IdentitySchemaResponse: &resource.IdentitySchemaResponse{ + Schema: &tfprotov6.ResourceIdentitySchema{ + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + RequiredForImport: true, + }, + { + Name: "list_of_numbers", + Type: tftypes.List{ElementType: tftypes.Number}, + OptionalForImport: true, + }, + }, + }, + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "name", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }) +} + +func examplecloudProviderNoIdentity() func() (tfprotov6.ProviderServer, error) { + return providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_thing": { + CreateResponse: &resource.CreateResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test value"), + }, + ), + }, + ReadResponse: &resource.ReadResponse{ + NewState: tftypes.NewValue( + tftypes.Object{ + AttributeTypes: map[string]tftypes.Type{ + "name": tftypes.String, + }, + }, + map[string]tftypes.Value{ + "name": tftypes.NewValue(tftypes.String, "test value"), + }, + ), + }, + SchemaResponse: &resource.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "name", + Type: tftypes.String, + Computed: true, + }, + }, + }, + }, + }, + }, + }, + }) +} diff --git a/tfversion/versions.go b/tfversion/versions.go index ac734e598..ffb625c8d 100644 --- a/tfversion/versions.go +++ b/tfversion/versions.go @@ -38,4 +38,5 @@ var ( Version1_9_0 *version.Version = version.Must(version.NewVersion("1.9.0")) Version1_10_0 *version.Version = version.Must(version.NewVersion("1.10.0")) Version1_11_0 *version.Version = version.Must(version.NewVersion("1.11.0")) + Version1_12_0 *version.Version = version.Must(version.NewVersion("1.12.0")) )