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/helper/resource/importstate/examplecloud_test.go b/helper/resource/importstate/examplecloud_test.go index c7086d6c3..a8a88d552 100644 --- a/helper/resource/importstate/examplecloud_test.go +++ b/helper/resource/importstate/examplecloud_test.go @@ -75,6 +75,18 @@ func examplecloudResource() testprovider.Resource { }, ), }, + IdentitySchemaResponse: &resource.IdentitySchemaResponse{ + Schema: &tfprotov6.ResourceIdentitySchema{ + Version: 1, + IdentityAttributes: []*tfprotov6.ResourceIdentitySchemaAttribute{ + { + Name: "id", + Type: tftypes.String, + RequiredForImport: true, + }, + }, + }, + }, ImportStateResponse: &resource.ImportStateResponse{ State: tftypes.NewValue( tftypes.Object{ diff --git a/helper/resource/importstate/import_block_as_first_step_test.go b/helper/resource/importstate/import_block_as_first_step_test.go new file mode 100644 index 000000000..91d97d28d --- /dev/null +++ b/helper/resource/importstate/import_block_as_first_step_test.go @@ -0,0 +1,56 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "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/knownvalue" + "github.com/hashicorp/terraform-plugin-testing/plancheck" + "github.com/hashicorp/terraform-plugin-testing/tfjsonpath" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func Test_ImportBlock_AsFirstStep(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + ResourceName: "examplecloud_container.test", + ImportStateId: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + Config: `resource "examplecloud_container" "test" { + name = "somevalue" + location = "westeurope" + }`, + ImportPlanChecks: r.ImportPlanChecks{ + PreApply: []plancheck.PlanCheck{ + plancheck.ExpectResourceAction("examplecloud_container.test", plancheck.ResourceActionNoop), + plancheck.ExpectKnownValue("examplecloud_container.test", tfjsonpath.New("id"), knownvalue.StringExact("westeurope/somevalue")), + plancheck.ExpectKnownValue("examplecloud_container.test", tfjsonpath.New("name"), knownvalue.StringExact("somevalue")), + plancheck.ExpectKnownValue("examplecloud_container.test", tfjsonpath.New("location"), knownvalue.StringExact("westeurope")), + }, + }, + }, + }, + }) +} diff --git a/helper/resource/importstate/import_block_in_config_directory_test.go b/helper/resource/importstate/import_block_in_config_directory_test.go new file mode 100644 index 000000000..d1b5ad54e --- /dev/null +++ b/helper/resource/importstate/import_block_in_config_directory_test.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "github.com/hashicorp/terraform-plugin-testing/config" + "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/tfversion" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func Test_ImportBlock_InConfigDirectory(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + ConfigDirectory: func(config.TestStepConfigRequest) string { + return `testdata/1` + }, + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ImportPlanVerify: true, + ConfigDirectory: func(config.TestStepConfigRequest) string { + return `testdata/2` + }, + }, + }, + }) +} diff --git a/helper/resource/importstate/import_block_in_config_file_test.go b/helper/resource/importstate/import_block_in_config_file_test.go new file mode 100644 index 000000000..8caed999e --- /dev/null +++ b/helper/resource/importstate/import_block_in_config_file_test.go @@ -0,0 +1,50 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "github.com/hashicorp/terraform-plugin-testing/config" + "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/tfversion" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func Test_ImportBlock_InConfigFile(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + ConfigFile: func(config.TestStepConfigRequest) string { + return `testdata/1/examplecloud_container.tf` + }, + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ImportPlanVerify: true, + ConfigFile: func(config.TestStepConfigRequest) string { + return `testdata/2/examplecloud_container_import.tf` + }, + }, + }, + }) +} diff --git a/helper/resource/importstate/import_block_verify_plan_test.go b/helper/resource/importstate/import_block_verify_plan_test.go new file mode 100644 index 000000000..3568c2f57 --- /dev/null +++ b/helper/resource/importstate/import_block_verify_plan_test.go @@ -0,0 +1,48 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "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/tfversion" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func Test_ImportBlock_VerifyPlan(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + Config: ` + resource "examplecloud_container" "test" { + location = "westeurope" + name = "somevalue" + }`, + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ImportPlanVerify: true, + }, + }, + }) +} diff --git a/helper/resource/importstate/import_block_with_id_test.go b/helper/resource/importstate/import_block_with_id_test.go index 82d949728..88d8949c6 100644 --- a/helper/resource/importstate/import_block_with_id_test.go +++ b/helper/resource/importstate/import_block_with_id_test.go @@ -21,12 +21,12 @@ import ( r "github.com/hashicorp/terraform-plugin-testing/helper/resource" ) -func TestTest_TestStep_ImportBlockId(t *testing.T) { +func Test_TestStep_ImportBlockId(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithId requires Terraform 1.5.0 or later + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ @@ -44,10 +44,9 @@ func TestTest_TestStep_ImportBlockId(t *testing.T) { }`, }, { - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithId, - ImportStateVerify: true, + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, }, }, }) @@ -58,7 +57,7 @@ func TestTest_TestStep_ImportBlockId_ExpectError(t *testing.T) { r.UnitTest(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithId requires Terraform 1.5.0 or later + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ @@ -81,11 +80,10 @@ func TestTest_TestStep_ImportBlockId_ExpectError(t *testing.T) { location = "eastus" name = "somevalue" }`, - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithId, - ImportStateVerify: true, - ExpectError: regexp.MustCompile(`importing resource examplecloud_container.test should be a no-op, but got action update with plan(.?)`), + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ExpectError: regexp.MustCompile(`importing resource examplecloud_container.test: expected a no-op resource action, got "update" action with plan(.?)`), }, }, }) @@ -120,11 +118,10 @@ func TestTest_TestStep_ImportBlockId_FailWhenPlannableImportIsNotSupported(t *te location = "eastus" name = "somevalue" }`, - ResourceName: "examplecloud_container.test", - ImportState: true, - ImportStateKind: r.ImportBlockWithId, - ImportStateVerify: true, - ExpectError: regexp.MustCompile(`Terraform 1.5.0`), + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithID, + ExpectError: regexp.MustCompile(`Terraform 1.5.0`), }, }, }) @@ -135,7 +132,7 @@ func TestTest_TestStep_ImportBlockId_SkipDataSourceState(t *testing.T) { r.UnitTest(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithId requires Terraform 1.5.0 or later + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ @@ -161,7 +158,7 @@ func TestTest_TestStep_ImportBlockId_SkipDataSourceState(t *testing.T) { { ResourceName: "examplecloud_thing.test", ImportState: true, - ImportStateKind: r.ImportBlockWithId, + ImportStateKind: r.ImportBlockWithID, ImportStateCheck: func(is []*terraform.InstanceState) error { if len(is) > 1 { return fmt.Errorf("expected 1 state, got: %d", len(is)) @@ -200,11 +197,16 @@ func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore_Real_Example(t *tes I also need to omit the `password` in the import config, otherwise the value in the config is used when importing the with an import block and the test ends up passing regardless of whether `ImportStateVerifyIgnore` has been specified or not */ + + // In prerelease, we are choosing that ImportBlockWithID will not perform an apply, so it will not produce a new state, + // and there is no new state for ImportStateVerify to do anything meaningful with. + t.Skip() + t.Parallel() r.UnitTest(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithId requires Terraform 1.5.0 or later + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ @@ -292,7 +294,7 @@ func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore_Real_Example(t *tes }`, ResourceName: "examplecloud_container.test", ImportState: true, - ImportStateKind: r.ImportBlockWithId, + ImportStateKind: r.ImportBlockWithID, ImportStateVerify: true, ImportStateVerifyIgnore: []string{"password"}, }, @@ -301,11 +303,15 @@ func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore_Real_Example(t *tes } func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore(t *testing.T) { + // In prerelease, we are choosing that ImportBlockWithID will not perform an apply, so it will not produce a new state, + // and there is no new state for ImportStateVerify to do anything meaningful with. + t.Skip() + t.Parallel() r.UnitTest(t, r.TestCase{ TerraformVersionChecks: []tfversion.TerraformVersionCheck{ - tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithId requires Terraform 1.5.0 or later + tfversion.SkipBelow(tfversion.Version1_5_0), // ImportBlockWithID requires Terraform 1.5.0 or later }, ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ @@ -377,7 +383,7 @@ func TestTest_TestStep_ImportBlockId_ImportStateVerifyIgnore(t *testing.T) { { ResourceName: "examplecloud_container.test", ImportState: true, - ImportStateKind: r.ImportBlockWithId, + ImportStateKind: r.ImportBlockWithID, ImportStateVerify: true, ImportStateVerifyIgnore: []string{"password"}, }, diff --git a/helper/resource/importstate/import_command_as_first_step_test.go b/helper/resource/importstate/import_command_as_first_step_test.go new file mode 100644 index 000000000..771ea8dc4 --- /dev/null +++ b/helper/resource/importstate/import_command_as_first_step_test.go @@ -0,0 +1,62 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_test + +import ( + "fmt" + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "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/terraform" + "github.com/hashicorp/terraform-plugin-testing/tfversion" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func Test_ImportCommand_AsFirstStep(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_0_0), // ProtoV6ProviderFactories needs Terraform 1.0.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + ResourceName: "examplecloud_container.test", + ImportStateId: "examplecloud_container.test", + ImportState: true, + Config: `resource "examplecloud_container" "test" { + name = "somevalue" + location = "westeurope" + }`, + ImportStatePersist: true, + ImportStateCheck: func(states []*terraform.InstanceState) error { + if len(states) != 1 { + return fmt.Errorf("expected 1 state; got %d", len(states)) + } + if states[0].ID != "westeurope/somevalue" { + return fmt.Errorf("unexpected ID: %s", states[0].ID) + } + if states[0].Attributes["name"] != "somevalue" { + return fmt.Errorf("unexpected name: %s", states[0].Attributes["name"]) + } + if states[0].Attributes["location"] != "westeurope" { + return fmt.Errorf("unexpected location: %s", states[0].Attributes["location"]) + } + return nil + }, + }, + }, + }) +} diff --git a/helper/resource/importstate/import_command_with_id_test.go b/helper/resource/importstate/import_command_with_id_test.go index df6e43460..48187bba9 100644 --- a/helper/resource/importstate/import_command_with_id_test.go +++ b/helper/resource/importstate/import_command_with_id_test.go @@ -20,7 +20,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/tfversion" ) -func Test_TestStep_ImportStateCheck_SkipDataSourceState(t *testing.T) { +func Test_ImportCommmandWithId_SkipDataSourceState(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ @@ -123,7 +123,7 @@ func Test_TestStep_ImportStateCheck_SkipDataSourceState(t *testing.T) { }) } -func Test_TestStep_ImportStateVerify(t *testing.T) { +func Test_ImportCommandWithId_ImportStateVerify(t *testing.T) { t.Parallel() r.UnitTest(t, r.TestCase{ diff --git a/helper/resource/importstate/resource_identity_test.go b/helper/resource/importstate/resource_identity_test.go new file mode 100644 index 000000000..537592c78 --- /dev/null +++ b/helper/resource/importstate/resource_identity_test.go @@ -0,0 +1,47 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package importstate_test + +import ( + "testing" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + + "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/tfversion" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" +) + +func Test_ResourceIdentity(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_12_0), // TODO: ImportBlockWithResourceIdentity requires Terraform 1.12.0 or later + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + Resources: map[string]testprovider.Resource{ + "examplecloud_container": examplecloudResource(), + }, + }), + }, + Steps: []r.TestStep{ + { + Config: ` + resource "examplecloud_container" "test" { + location = "westeurope" + name = "somevalue" + }`, + }, + { + ResourceName: "examplecloud_container.test", + ImportState: true, + ImportStateKind: r.ImportBlockWithResourceIdentity, + }, + }, + }) +} diff --git a/helper/resource/importstate/testdata/1/examplecloud_container.tf b/helper/resource/importstate/testdata/1/examplecloud_container.tf new file mode 100644 index 000000000..ccfb698e6 --- /dev/null +++ b/helper/resource/importstate/testdata/1/examplecloud_container.tf @@ -0,0 +1,7 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +resource "examplecloud_container" "test" { + name = "somevalue" + location = "westeurope" +} diff --git a/helper/resource/importstate/testdata/2/examplecloud_container_import.tf b/helper/resource/importstate/testdata/2/examplecloud_container_import.tf new file mode 100644 index 000000000..f7e9411f9 --- /dev/null +++ b/helper/resource/importstate/testdata/2/examplecloud_container_import.tf @@ -0,0 +1,12 @@ +# Copyright (c) HashiCorp, Inc. +# SPDX-License-Identifier: MPL-2.0 + +resource "examplecloud_container" "test" { + name = "somevalue" + location = "westeurope" +} + +import { + to = examplecloud_container.test + id = "examplecloud_container.test" +} diff --git a/helper/resource/testing.go b/helper/resource/testing.go index c7bdcd75e..e4c818e07 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -17,6 +17,7 @@ import ( "github.com/mitchellh/go-testing-interface" + "github.com/hashicorp/go-version" "github.com/hashicorp/terraform-plugin-go/tfprotov5" "github.com/hashicorp/terraform-plugin-go/tfprotov6" @@ -457,14 +458,47 @@ type ExternalProvider struct { type ImportStateKind byte const ( - // ImportCommandWithId imports the state using the import command - ImportCommandWithId ImportStateKind = iota - // ImportBlockWithId imports the state using an import block with an ID - ImportBlockWithId + // ImportCommandWithID imports the state using the import command + ImportCommandWithID ImportStateKind = iota + + // ImportBlockWithID imports the state using an import block with an ID + ImportBlockWithID + // ImportBlockWithResourceIdentity imports the state using an import block with a resource identity ImportBlockWithResourceIdentity ) +// plannable returns true if this ImportStateKind uses the plannable import feature +func (kind ImportStateKind) plannable() bool { + return kind == ImportBlockWithID || kind == ImportBlockWithResourceIdentity +} + +// resourceIdentity returns true if this ImportStateKind uses resource identity +func (kind ImportStateKind) resourceIdentity() bool { + return kind == ImportBlockWithResourceIdentity +} + +// terraformVersion returns the minimum Terraform version that supports +// the features required for this ImportStateKind. +func (kind ImportStateKind) terraformVersion() *version.Version { + switch kind { + case ImportBlockWithID: + return tfversion.Version1_5_0 + case ImportBlockWithResourceIdentity: + return tfversion.Version1_12_0 + default: + return tfversion.Version0_12_26 // Default to the earlist version supported by the testing framework + } +} + +func (kind ImportStateKind) String() string { + return map[ImportStateKind]string{ + ImportCommandWithID: "ImportCommandWithID", + ImportBlockWithID: "ImportBlockWithID", + ImportBlockWithResourceIdentity: "ImportBlockWithResourceIdentity", + }[kind] +} + // TestStep is a single apply sequence of a test, done within the // context of a state. // @@ -679,6 +713,30 @@ type TestStep struct { // Terraform version specific logic in provider testing. ImportStateCheck ImportStateCheckFunc + // ImportPlanChecks allows assertions to be made against the plan file at different points of a plannable import test using a plan check. + // Custom plan checks can be created by implementing the [PlanCheck] interface, or by using a PlanCheck implementation from the provided [plancheck] package + // + // [PlanCheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck#PlanCheck + // [plancheck]: https://pkg.go.dev/github.com/hashicorp/terraform-plugin-testing/plancheck + ImportPlanChecks ImportPlanChecks + + // ImportPlanVerify checks that a generated plan for a plannable import + // (ImportBlockWithID or ImportBlockWithResourceIdentity) agrees with + // the known state of a previous test stepstate values + // that are finally put into the state after import match for all the + // IDs returned by the Import. Note that this checks for strict equality + // and does not respect DiffSuppressFunc or CustomizeDiff. + // + // By default, the prior resource state and import resource state are + // matched by the "id" attribute. If the "id" attribute is not implemented + // or another attribute more uniquely identifies the resource, set the + // ImportStateVerifyIdentifierAttribute field to adjust the attribute for + // matching. + // + // If certain attributes cannot be correctly imported, set the + // ImportStateVerifyIgnore field. + ImportPlanVerify bool + // ImportStateVerify, if true, will also check that the state values // that are finally put into the state after import match for all the // IDs returned by the Import. Note that this checks for strict equality @@ -810,6 +868,13 @@ type ConfigPlanChecks struct { PostApplyPostRefresh []plancheck.PlanCheck } +// ImportPlanChecks defines the different points in an Import TestStep when plan checks can be run. +type ImportPlanChecks struct { + // PreApply runs all plan checks in the slice. This occurs after the plan of an Import test is computed. This slice cannot be populated + // with TestStep.PlanOnly, as there is no PreApply plan run with that flag set. All errors by plan checks in this slice are aggregated, reported, and will result in a test failure. + PreApply []plancheck.PlanCheck +} + // RefreshPlanChecks defines the different points in a Refresh TestStep when plan checks can be run. type RefreshPlanChecks struct { // PostRefresh runs all plan checks in the slice. This occurs after the refresh of the Refresh test is run. @@ -953,17 +1018,17 @@ func UnitTest(t testing.T, c TestCase) { Test(t, c) } -func testResource(c TestStep, state *terraform.State) (*terraform.ResourceState, error) { +func testResource(name string, state *terraform.State) (*terraform.ResourceState, error) { for _, m := range state.Modules { if len(m.Resources) > 0 { - if v, ok := m.Resources[c.ResourceName]; ok { + if v, ok := m.Resources[name]; ok { return v, nil } } } return nil, fmt.Errorf( - "Resource specified by ResourceName couldn't be found: %s", c.ResourceName) + "Resource specified by ResourceName couldn't be found: %s", name) } // ComposeTestCheckFunc lets you compose multiple TestCheckFuncs into diff --git a/helper/resource/testing_new_import_state.go b/helper/resource/testing_new_import_state.go index 5cd4f50e8..c03bc8b3e 100644 --- a/helper/resource/testing_new_import_state.go +++ b/helper/resource/testing_new_import_state.go @@ -10,6 +10,7 @@ import ( "strings" "github.com/hashicorp/go-version" + tfjson "github.com/hashicorp/terraform-json" "github.com/google/go-cmp/cmp" @@ -21,38 +22,19 @@ import ( "github.com/hashicorp/terraform-plugin-testing/internal/plugintest" "github.com/hashicorp/terraform-plugin-testing/internal/teststep" "github.com/hashicorp/terraform-plugin-testing/terraform" - "github.com/hashicorp/terraform-plugin-testing/tfversion" ) -func requirePlannableImport(t testing.T, versionUnderTest version.Version) error { - t.Helper() - - if versionUnderTest.LessThan(tfversion.Version1_5_0) { - return fmt.Errorf( - `ImportState steps using plannable import blocks require Terraform 1.5.0 or later. Either ` + - `upgrade the Terraform version running the test or add a ` + "`TerraformVersionChecks`" + ` to ` + - `the test case to skip this test.` + "\n\n" + - `https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/tfversion-checks#skip-version-checks`) - } - - return nil -} - func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest.Helper, wd *plugintest.WorkingDir, step TestStep, cfgRaw string, providers *providerFactories, stepNumber int) error { t.Helper() - // Currently import modes `ImportBlockWithId` and `ImportBlockWithResourceIdentity` cannot support config file or directory - // since these modes append the import block to the configuration automatically - if step.ImportStateKind != ImportCommandWithId { - if step.ConfigFile != nil || step.ConfigDirectory != nil { - t.Fatalf("ImportStateKind %q is not supported for config file or directory", step.ImportStateKind) - } - } + // step.ImportStateKind implicitly defaults to the zero-value (ImportCommandWithID) for backward compatibility + kind := step.ImportStateKind - if step.ImportStateKind != ImportCommandWithId { - if err := requirePlannableImport(t, *helper.TerraformVersion()); err != nil { - return err - } + // Instead of calling [t.Fatal], return an error. This package's unit tests can use [TestStep.ExpectError] to match on the error message. + // An alternative, [plugintest.TestExpectTFatal], does not have access to logged error messages, so it is open to false positives on this + // complex code path. + if err := checkTerraformVersion(t, kind, *helper.TerraformVersion()); err != nil { + return err } configRequest := teststep.PrepareConfigurationRequest{ @@ -67,7 +49,8 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest testStepConfig := teststep.Configuration(configRequest) - if step.ResourceName == "" { + resourceName := step.ResourceName + if resourceName == "" { t.Fatal("ResourceName is required for an import state test") } @@ -87,7 +70,7 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest } // Determine the ID to import - var importId string + var importId string //nolint:revive switch { case step.ImportStateIdFunc != nil: logging.HelperResourceTrace(ctx, "Using TestStep ImportStateIdFunc for import identifier") @@ -110,7 +93,7 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest default: logging.HelperResourceTrace(ctx, "Using resource identifier for import identifier") - resource, err := testResource(step, state) + resource, err := testResource(resourceName, state) if err != nil { t.Fatal(err) } @@ -125,6 +108,7 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest logging.HelperResourceTrace(ctx, fmt.Sprintf("Using import identifier: %s", importId)) + // Append to previous step config unless using ConfigFile or ConfigDirectory if testStepConfig == nil || step.Config != "" { importConfig := step.Config if importConfig == "" { @@ -132,19 +116,12 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest importConfig = cfgRaw } - // Update the test config dependent on the kind of import test being performed - switch step.ImportStateKind { - case ImportBlockWithResourceIdentity: - t.Fatalf("TODO implement me") - case ImportBlockWithId: - importConfig += fmt.Sprintf(` - import { - to = %s - id = %q + if kind.plannable() { + if kind.resourceIdentity() { + importConfig = appendImportWithResourceIDBlock(importConfig, resourceName, importId) + } else { + importConfig = appendImportWithIDBlock(importConfig, resourceName, importId) } - `, step.ResourceName, importId) - default: - // Not an import block test so nothing to do here } confRequest := teststep.PrepareConfigurationRequest{ @@ -170,7 +147,7 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest importWd = wd } else { importWd = helper.RequireNewWorkingDir(ctx, t, "") - defer importWd.Close() + defer importWd.Close() //nolint:errcheck } err = importWd.SetConfig(ctx, testStepConfig, step.ConfigVariables) @@ -178,10 +155,9 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest t.Fatalf("Error setting test config: %s", err) } - logging.HelperResourceDebug(ctx, "Running Terraform CLI init and import") - if !step.ImportStatePersist { err = runProviderCommand(ctx, t, func() error { + logging.HelperResourceDebug(ctx, "Run terraform init") return importWd.Init(ctx) }, importWd, providers) if err != nil { @@ -189,19 +165,21 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest } } - if step.ImportStateKind == ImportBlockWithResourceIdentity || step.ImportStateKind == ImportBlockWithId { + var plan *tfjson.Plan + if kind.plannable() { var opts []tfexec.PlanOption err = runProviderCommand(ctx, t, func() error { + logging.HelperResourceDebug(ctx, "Run terraform plan") return importWd.CreatePlan(ctx, opts...) }, importWd, providers) if err != nil { return err } - var plan *tfjson.Plan err = runProviderCommand(ctx, t, func() error { var err error + logging.HelperResourceDebug(ctx, "Run terraform show") plan, err = importWd.SavedPlan(ctx) return err }, importWd, providers) @@ -209,37 +187,26 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest return err } - if plan.ResourceChanges != nil { - for _, rc := range plan.ResourceChanges { - if rc.Address != step.ResourceName { - // we're only interested in the changes for the resource being imported - continue - } - if rc.Change != nil && rc.Change.Actions != nil { - // should this be length checked and used as a condition, if it's a no-op then there shouldn't be any other changes here - for _, action := range rc.Change.Actions { - if action != "no-op" { - var stdout string - err = runProviderCommand(ctx, t, func() error { - var err error - stdout, err = importWd.SavedPlanRawStdout(ctx) - return err - }, importWd, providers) - if err != nil { - return fmt.Errorf("retrieving formatted plan output: %w", err) - } - - return fmt.Errorf("importing resource %s should be a no-op, but got action %s with plan \\nstdout:\\n\\n%s", rc.Address, action, stdout) - } - } + if len(plan.ResourceChanges) > 0 { + logging.HelperResourceDebug(ctx, fmt.Sprintf("ImportBlockWithId: %d resource changes", len(plan.ResourceChanges))) + + if err := requireNoopResourceAction(ctx, t, plan, resourceName, importWd, providers); err != nil { + return err + } + + if step.ImportPlanVerify { + if err := teststep.VerifyImportPlan(plan, state); err != nil { + return err } } } - // TODO compare plan to state from previous step + if err := runPlanChecks(ctx, t, plan, step.ImportPlanChecks.PreApply); err != nil { + return err + } } else { err = runProviderCommand(ctx, t, func() error { - return importWd.Import(ctx, step.ResourceName, importId) + return importWd.Import(ctx, resourceName, importId) }, importWd, providers) if err != nil { return err @@ -258,32 +225,12 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest t.Fatalf("Error getting state: %s", err) } + logging.HelperResourceDebug(ctx, fmt.Sprintf("State after import: %d resources in the root module", len(importState.RootModule().Resources))) + // Go through the imported state and verify if step.ImportStateCheck != nil { logging.HelperResourceTrace(ctx, "Using TestStep ImportStateCheck") - - var states []*terraform.InstanceState - for address, r := range importState.RootModule().Resources { - if strings.HasPrefix(address, "data.") { - continue - } - - if r.Primary == nil { - continue - } - - is := r.Primary.DeepCopy() //nolint:staticcheck // legacy usage - is.Ephemeral.Type = r.Type // otherwise the check function cannot see the type - states = append(states, is) - } - - logging.HelperResourceDebug(ctx, "Calling TestStep ImportStateCheck") - - if err := step.ImportStateCheck(states); err != nil { - t.Fatal(err) - } - - logging.HelperResourceDebug(ctx, "Called TestStep ImportStateCheck") + runImportStateCheckFunction(ctx, t, importState, step) } // Verify that all the states match @@ -313,6 +260,10 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest identifierAttribute = "id" } + if len(newResources) == 0 { + return fmt.Errorf("ImportStateVerify: no new resources imported") + } + for _, r := range newResources { rIdentifier, ok := r.Primary.Attributes[identifierAttribute] @@ -426,3 +377,104 @@ func testStepNewImportState(ctx context.Context, t testing.T, helper *plugintest return nil } + +func requireNoopResourceAction(ctx context.Context, t testing.T, plan *tfjson.Plan, resourceName string, importWd *plugintest.WorkingDir, providers *providerFactories) error { + t.Helper() + + rc := findResourceChangeInPlan(t, plan, resourceName) + if rc == nil || rc.Change == nil || rc.Change.Actions == nil { + // does this matter? + return nil + } + + // should this be length checked and used as a condition, if it's a no-op then there shouldn't be any other changes here + for _, action := range rc.Change.Actions { + if action != "no-op" { + var stdout string + err := runProviderCommand(ctx, t, func() error { + var err error + stdout, err = importWd.SavedPlanRawStdout(ctx) + return err + }, importWd, providers) + if err != nil { + return fmt.Errorf("retrieving formatted plan output: %w", err) + } + + return fmt.Errorf("importing resource %s: expected a no-op resource action, got %q action with plan \nstdout:\n\n%s", rc.Address, action, stdout) + } + } + + return nil +} + +func findResourceChangeInPlan(t testing.T, plan *tfjson.Plan, resourceName string) *tfjson.ResourceChange { + t.Helper() + + for _, rc := range plan.ResourceChanges { + if rc.Address == resourceName { + return rc + } + } + return nil +} + +func appendImportWithIDBlock(config string, resourceName string, importID string) string { + return config + fmt.Sprintf(``+"\n"+ + `import {`+"\n"+ + ` to = %s`+"\n"+ + ` id = %q`+"\n"+ + `}`, + resourceName, importID) +} + +func appendImportWithResourceIDBlock(config string, resourceName string, importID string) string { + return config + fmt.Sprintf(``+"\n"+ + `import {`+"\n"+ + ` to = %s`+"\n"+ + ` identity = {`+"\n"+ + ` // Add identity attributes here`+"\n"+ + ` }`+"\n"+ + `}`+"\n", + resourceName) +} + +func checkTerraformVersion(t testing.T, kind ImportStateKind, versionUnderTest version.Version) error { + t.Helper() + + if versionUnderTest.Core().LessThan(kind.terraformVersion()) { + return fmt.Errorf( + `%s steps require Terraform %s. Detected Terraform %s. Either upgrade the Terraform version running the test `+ + `or add `+"`TerraformVersionChecks`"+` to the test case to skip this test.`+"\n\n"+ + `https://developer.hashicorp.com/terraform/plugin/testing/acceptance-tests/tfversion-checks#skip-version-checks`, + kind, kind.terraformVersion(), versionUnderTest.String()) + } + + return nil +} + +func runImportStateCheckFunction(ctx context.Context, t testing.T, importState *terraform.State, step TestStep) { + t.Helper() + + var states []*terraform.InstanceState + for address, r := range importState.RootModule().Resources { + if strings.HasPrefix(address, "data.") { + continue + } + + if r.Primary == nil { + continue + } + + is := r.Primary.DeepCopy() //nolint:staticcheck // legacy usage + is.Ephemeral.Type = r.Type // otherwise the check function cannot see the type + states = append(states, is) + } + + logging.HelperResourceTrace(ctx, "Calling TestStep ImportStateCheck") + + if err := step.ImportStateCheck(states); err != nil { + t.Fatal(err) + } + + logging.HelperResourceTrace(ctx, "Called TestStep ImportStateCheck") +} 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..28a8e02c9 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 { + newIdentity, diag := IdentityValuetoDynamicValue(identitySchemaResp.Schema, *newIdentity) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + resp.NewIdentity = &tfprotov6.ResourceIdentityData{ + IdentityData: newIdentity, + } } 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/internal/teststep/verify_import_plan.go b/internal/teststep/verify_import_plan.go new file mode 100644 index 000000000..6518831de --- /dev/null +++ b/internal/teststep/verify_import_plan.go @@ -0,0 +1,71 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package teststep + +import ( + "fmt" + "strings" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +// VerifyImportPlan compares a Terraform plan against a known good state +func VerifyImportPlan(plan *tfjson.Plan, state *terraform.State) error { + if state == nil { + return fmt.Errorf("state is nil") + } + if plan == nil { + return fmt.Errorf("plan is nil") + } + oldResources := make(map[string]*terraform.ResourceState) + for logicalResourceName, resourceState := range state.RootModule().Resources { + if !strings.HasPrefix(logicalResourceName, "data.") { + oldResources[logicalResourceName] = resourceState + } + } + for _, rc := range plan.ResourceChanges { + if rc.Change == nil || rc.Change.Actions == nil { + // does this matter? + continue + } + + if !rc.Change.Actions.NoOp() { + return fmt.Errorf("importing resource %s: expected a no-op resource action, got %q action", rc.Address, rc.Change.Actions) + } + + if rc.Change.Importing == nil { + return fmt.Errorf("importing resource %s: expected importing to be true", rc.Address) + } + } + + for _, rc := range plan.ResourceChanges { + after, ok := rc.Change.After.(map[string]interface{}) + if !ok { + panic(fmt.Sprintf("unexpected type %T", rc.Change.After)) + } + + for k, v := range after { + vs, ok := v.(string) + if !ok { + panic(fmt.Sprintf("unexpected type %T", v)) + } + + oldResource := oldResources[rc.Address] + if oldResource == nil { + // does this matter? + return fmt.Errorf("importing resource %s: expected resource %s to exist in known state", rc.Address, rc.Change.Importing.ID) + } + + attr, ok := oldResource.Primary.Attributes[k] + if !ok { + return fmt.Errorf("importing resource %s: expected %s in known state to exist", rc.Address, k) + } + if attr != vs { + return fmt.Errorf("importing resource %s: expected %s in known state to be %q, got %q", rc.Address, k, oldResource.Primary.Attributes[k], vs) + } + } + } + return nil +} diff --git a/internal/teststep/verify_import_plan_test.go b/internal/teststep/verify_import_plan_test.go new file mode 100644 index 000000000..fd11b436f --- /dev/null +++ b/internal/teststep/verify_import_plan_test.go @@ -0,0 +1,58 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: MPL-2.0 + +package teststep_test + +import ( + "testing" + + tfjson "github.com/hashicorp/terraform-json" + "github.com/hashicorp/terraform-plugin-testing/internal/teststep" + "github.com/hashicorp/terraform-plugin-testing/terraform" +) + +func Test_VerifyImportPlan(t *testing.T) { + t.Parallel() + + state := &terraform.State{ + Version: 6, + Modules: []*terraform.ModuleState{ + { + Path: []string{"root"}, + Resources: map[string]*terraform.ResourceState{ + "example_resource.instance-1": { + Primary: &terraform.InstanceState{ + Attributes: map[string]string{ + "attr1": "value1", + }, + }, + }, + }, + }, + }, + } + + plan := new(tfjson.Plan) + plan.ResourceChanges = []*tfjson.ResourceChange{ + { + Address: "example_resource.instance-1", + Change: &tfjson.Change{ + Actions: []tfjson.Action{tfjson.ActionNoop}, + After: map[string]interface{}{ + "attr1": "value1", + }, + Before: map[string]interface{}{ + "attr1": "value1", + }, + Importing: &tfjson.Importing{ + ID: "instance-1", + }, + }, + }, + } + + if err := teststep.VerifyImportPlan(plan, state); err != nil { + t.Fatal(err) + } + +} 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")) )