diff --git a/.changes/unreleased/FEATURES-20260109-170955.yaml b/.changes/unreleased/FEATURES-20260109-170955.yaml new file mode 100644 index 000000000..32b771ae4 --- /dev/null +++ b/.changes/unreleased/FEATURES-20260109-170955.yaml @@ -0,0 +1,5 @@ +kind: FEATURES +body: 'helper/resource: Added new `StateStore` testing mode to `TestStep`, which can be used to smoke test Terraform state storage.' +time: 2026-01-09T17:09:55.56054-05:00 +custom: + Issue: "591" diff --git a/go.mod b/go.mod index fe3f8a8db..1bdcf43b5 100644 --- a/go.mod +++ b/go.mod @@ -11,9 +11,9 @@ require ( github.com/hashicorp/hc-install v0.9.2 github.com/hashicorp/hcl/v2 v2.24.0 github.com/hashicorp/logutils v1.0.0 - github.com/hashicorp/terraform-exec v0.24.0 + github.com/hashicorp/terraform-exec v0.24.1-0.20260121172827-e91e9600ec9b github.com/hashicorp/terraform-json v0.27.2 - github.com/hashicorp/terraform-plugin-go v0.29.0 + github.com/hashicorp/terraform-plugin-go v0.29.1-0.20260122204301-b0a6aca80fd7 github.com/hashicorp/terraform-plugin-log v0.10.0 github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 github.com/mitchellh/go-testing-interface v1.14.1 @@ -54,7 +54,7 @@ require ( golang.org/x/text v0.33.0 // indirect golang.org/x/tools v0.40.0 // indirect google.golang.org/appengine v1.6.8 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7 // indirect - google.golang.org/grpc v1.75.1 // indirect - google.golang.org/protobuf v1.36.9 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda // indirect + google.golang.org/grpc v1.78.0 // indirect + google.golang.org/protobuf v1.36.11 // indirect ) diff --git a/go.sum b/go.sum index efd472003..c53a2a6a1 100644 --- a/go.sum +++ b/go.sum @@ -77,12 +77,12 @@ github.com/hashicorp/hcl/v2 v2.24.0 h1:2QJdZ454DSsYGoaE6QheQZjtKZSUs9Nh2izTWiwQx github.com/hashicorp/hcl/v2 v2.24.0/go.mod h1:oGoO1FIQYfn/AgyOhlg9qLC6/nOJPX3qGbkZpYAcqfM= github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= -github.com/hashicorp/terraform-exec v0.24.0 h1:mL0xlk9H5g2bn0pPF6JQZk5YlByqSqrO5VoaNtAf8OE= -github.com/hashicorp/terraform-exec v0.24.0/go.mod h1:lluc/rDYfAhYdslLJQg3J0oDqo88oGQAdHR+wDqFvo4= +github.com/hashicorp/terraform-exec v0.24.1-0.20260121172827-e91e9600ec9b h1:fJUuEi2V7XraZP/KFvfEsfG+YoszjVp1jz1wRH2g3c0= +github.com/hashicorp/terraform-exec v0.24.1-0.20260121172827-e91e9600ec9b/go.mod h1:/+qarFaJUhdMK58b2hpaEEypGqsGPfauUuSOiengLr4= github.com/hashicorp/terraform-json v0.27.2 h1:BwGuzM6iUPqf9JYM/Z4AF1OJ5VVJEEzoKST/tRDBJKU= github.com/hashicorp/terraform-json v0.27.2/go.mod h1:GzPLJ1PLdUG5xL6xn1OXWIjteQRT2CNT9o/6A9mi9hE= -github.com/hashicorp/terraform-plugin-go v0.29.0 h1:1nXKl/nSpaYIUBU1IG/EsDOX0vv+9JxAltQyDMpq5mU= -github.com/hashicorp/terraform-plugin-go v0.29.0/go.mod h1:vYZbIyvxyy0FWSmDHChCqKvI40cFTDGSb3D8D70i9GM= +github.com/hashicorp/terraform-plugin-go v0.29.1-0.20260122204301-b0a6aca80fd7 h1:ViO8kjIwBFTqOrMT5GilsY8TKRjejAqSTyFjjdTMY1o= +github.com/hashicorp/terraform-plugin-go v0.29.1-0.20260122204301-b0a6aca80fd7/go.mod h1:q0rLI/Z8QaAhX+22MvIlGbFkHoOtwS58vaShJZPXndU= github.com/hashicorp/terraform-plugin-log v0.10.0 h1:eu2kW6/QBVdN4P3Ju2WiB2W3ObjkAsyfBsL3Wh1fj3g= github.com/hashicorp/terraform-plugin-log v0.10.0/go.mod h1:/9RR5Cv2aAbrqcTSdNmY1NRHP4E3ekrXRGjqORpXyB0= github.com/hashicorp/terraform-plugin-sdk/v2 v2.38.1 h1:mlAq/OrMlg04IuJT7NpefI1wwtdpWudnEmjuQs04t/4= @@ -155,18 +155,18 @@ github.com/zclconf/go-cty v1.17.0 h1:seZvECve6XX4tmnvRzWtJNHdscMtYEx5R7bnnVyd/d0 github.com/zclconf/go-cty v1.17.0/go.mod h1:wqFzcImaLTI6A5HfsRwB0nj5n0MRZFwmey8YoFPPs3U= 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/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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ= -go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I= -go.opentelemetry.io/otel/metric v1.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE= -go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E= -go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI= -go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg= -go.opentelemetry.io/otel/sdk/metric v1.37.0 h1:90lI228XrB9jCMuSdA0673aubgRobVZFhbjxHHspCPc= -go.opentelemetry.io/otel/sdk/metric v1.37.0/go.mod h1:cNen4ZWfiD37l5NhS+Keb5RXVWZWpRE+9WyVCpbo5ps= -go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4= -go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8= +go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM= +go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA= +go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI= +go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E= +go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg= +go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM= +go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA= +go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE= +go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs= 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.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8= @@ -221,14 +221,14 @@ gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E 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-20250707201910-8d1bb00bc6a7 h1:pFyd6EwwL2TqFf8emdthzeX+gZE1ElRq3iM8pui4KBY= -google.golang.org/genproto/googleapis/rpc v0.0.0-20250707201910-8d1bb00bc6a7/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A= -google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI= -google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda h1:i/Q+bfisr7gq6feoJnS/DlpdwEL4ihp41fvRiM3Ork0= +google.golang.org/genproto/googleapis/rpc v0.0.0-20251029180050-ab9386a59fda/go.mod h1:7i2o+ce6H/6BluujYR+kqX3GKH+dChPTQU19wjRPiGk= +google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= +google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= 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.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= -google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= +google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/helper/resource/statestore/examplecloud_statestore_test.go b/helper/resource/statestore/examplecloud_statestore_test.go new file mode 100644 index 000000000..d2a6247d6 --- /dev/null +++ b/helper/resource/statestore/examplecloud_statestore_test.go @@ -0,0 +1,104 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package statestore_test + +import ( + "context" + "errors" + "fmt" + "io" + "io/fs" + "path/filepath" + "strings" + "testing/fstest" + "time" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/internal/logging" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testprovider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/statestore" +) + +// This state store implementation uses an in-memory map for storing state in a filesystem-like manner. +// This only works because the terraform-plugin-testing harness keeps provider instances running +// throughout a Go test with multiple Terraform CLI command calls by using a reattach configuration. +func exampleCloudValidStateStore() *testprovider.StateStore { + memFS := fstest.MapFS{} + + return &testprovider.StateStore{ + SchemaResponse: &statestore.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{Attributes: []*tfprotov6.SchemaAttribute{}}, + }, + }, + GetStatesFunc: func(ctx context.Context, req statestore.GetStatesRequest, resp *statestore.GetStatesResponse) { + directories, err := memFS.ReadDir(".") + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Error reading inmem filesystem", + Detail: err.Error(), + }) + return + } + + workspaces := make([]string, 0) + for _, dir := range directories { + workspaces = append(workspaces, dir.Name()) + } + + resp.StateIDs = workspaces + }, + DeleteStateFunc: func(ctx context.Context, req statestore.DeleteStateRequest, resp *statestore.DeleteStateResponse) { + for filePath := range memFS { + if strings.HasPrefix(filePath, req.StateID) { + delete(memFS, filePath) + } + } + }, + LockStateFunc: func(ctx context.Context, req statestore.LockStateRequest, resp *statestore.LockStateResponse) { + logging.HelperResourceDebug(ctx, "examplecloud_inmem: Lock support not implemented") + }, + UnlockStateFunc: func(ctx context.Context, req statestore.UnlockStateRequest, resp *statestore.UnlockStateResponse) { + logging.HelperResourceDebug(ctx, "examplecloud_inmem: Lock support not implemented") + }, + ReadStateBytesFunc: func(ctx context.Context, req statestore.ReadStateBytesRequest, resp *statestore.ReadStateBytesResponse) { + stateFilePath := filepath.Join(req.StateID, "terraform.tfstate") + stateFile, err := memFS.Open(stateFilePath) + if err != nil { + // If there is no state file, Terraform will create one. + if errors.Is(err, fs.ErrNotExist) { + return + } + + resp.Diagnostics = append(resp.Diagnostics, &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: fmt.Sprintf("Error reading state %q at path %q", req.StateID, stateFilePath), + Detail: err.Error(), + }) + return + } + + stateBytes, err := io.ReadAll(stateFile) + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: fmt.Sprintf("Error reading %q state bytes", req.StateID), + Detail: err.Error(), + }) + return + } + + resp.StateBytes = stateBytes + }, + WriteStateBytesFunc: func(ctx context.Context, req statestore.WriteStateBytesRequest, resp *statestore.WriteStateBytesResponse) { + stateFilePath := filepath.Join(req.StateID, "terraform.tfstate") + memFS[stateFilePath] = &fstest.MapFile{ + Data: req.StateBytes, + Mode: fs.ModePerm, + ModTime: time.Now(), + } + }, + } +} diff --git a/helper/resource/statestore/statestore_test.go b/helper/resource/statestore/statestore_test.go new file mode 100644 index 000000000..0d6d72fde --- /dev/null +++ b/helper/resource/statestore/statestore_test.go @@ -0,0 +1,265 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package statestore_test + +import ( + "regexp" + "testing" + + "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/statestore" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +func TestStateStore_inmem_no_lock(t *testing.T) { + // Setting this environment variable ensures TF core uses pluggable state storage during init. + // This is only temporary while PSS is experimental. + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + r.UnitTest(t, r.TestCase{ + // State stores currently are only available in alpha releases or built from source + // with experiments enabled: `go install -ldflags="-X main.experimentsAllowed=yes" .` + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + StateStores: map[string]*testprovider.StateStore{ + "examplecloud_inmem": exampleCloudValidStateStore(), + }, + }), + }, + Steps: []r.TestStep{ + { + StateStore: true, + Config: ` + terraform { + required_providers { + examplecloud = { + source = "registry.terraform.io/hashicorp/examplecloud" + } + } + state_store "examplecloud_inmem" { + provider "examplecloud" {} + } + } + `, + }, + }, + }) +} + +func TestStateStore_validation_error(t *testing.T) { + // Setting this environment variable ensures TF core uses pluggable state storage during init. + // This is only temporary while PSS is experimental. + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + r.UnitTest(t, r.TestCase{ + // State stores currently are only available in alpha releases or built from source + // with experiments enabled: `go install -ldflags="-X main.experimentsAllowed=yes" .` + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + StateStores: map[string]*testprovider.StateStore{ + "examplecloud_inmem": { + SchemaResponse: &statestore.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{ + Attributes: []*tfprotov6.SchemaAttribute{ + { + Name: "path", + Type: tftypes.String, + Required: true, + }, + }, + }, + }, + }, + ValidateConfigResponse: &statestore.ValidateConfigResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "WHOOPS", + Detail: "Something isn't right about that request :D, error it is!", + Attribute: tftypes.NewAttributePath().WithAttributeName("path"), + }, + }, + }, + }, + }, + }), + }, + Steps: []r.TestStep{ + { + StateStore: true, + Config: ` + terraform { + required_providers { + examplecloud = { + source = "registry.terraform.io/hashicorp/examplecloud" + } + } + state_store "examplecloud_inmem" { + provider "examplecloud" {} + + path = "test_state_file.tfstate" + } + } + `, + ExpectError: regexp.MustCompile(`Something isn't right about that request :D, error it is!`), + }, + }, + }) +} + +func TestStateStore_configure_error(t *testing.T) { + // Setting this environment variable ensures TF core uses pluggable state storage during init. + // This is only temporary while PSS is experimental. + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + r.UnitTest(t, r.TestCase{ + // State stores currently are only available in alpha releases or built from source + // with experiments enabled: `go install -ldflags="-X main.experimentsAllowed=yes" .` + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + StateStores: map[string]*testprovider.StateStore{ + "examplecloud_inmem": { + SchemaResponse: &statestore.SchemaResponse{ + Schema: &tfprotov6.Schema{ + Block: &tfprotov6.SchemaBlock{Attributes: []*tfprotov6.SchemaAttribute{}}, + }, + }, + ConfigureResponse: &statestore.ConfigureResponse{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "WHOOPS", + Detail: "The configure has failed us! :P", + }, + }, + }, + }, + }, + }), + }, + Steps: []r.TestStep{ + { + StateStore: true, + Config: ` + terraform { + required_providers { + examplecloud = { + source = "registry.terraform.io/hashicorp/examplecloud" + } + } + state_store "examplecloud_inmem" { + provider "examplecloud" {} + } + } + `, + ExpectError: regexp.MustCompile(`The configure has failed us! :P`), + }, + }, + }) +} + +func TestStateStore_workspace_delete_error(t *testing.T) { + // Setting this environment variable ensures TF core uses pluggable state storage during init. + // This is only temporary while PSS is experimental. + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + // Simulating an invalid state store that doesn't support deleting workspaces + stateStoreImpl := exampleCloudValidStateStore() + stateStoreImpl.DeleteStateFunc = nil + + r.UnitTest(t, r.TestCase{ + // State stores currently are only available in alpha releases or built from source + // with experiments enabled: `go install -ldflags="-X main.experimentsAllowed=yes" .` + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + StateStores: map[string]*testprovider.StateStore{ + "examplecloud_inmem": stateStoreImpl, + }, + }), + }, + Steps: []r.TestStep{ + { + StateStore: true, + Config: ` + terraform { + required_providers { + examplecloud = { + source = "registry.terraform.io/hashicorp/examplecloud" + } + } + state_store "examplecloud_inmem" { + provider "examplecloud" {} + } + } + `, + ExpectError: regexp.MustCompile(`Workspace "bar" already exists`), + }, + }, + }) +} + +func TestStateStore_invalid_write_state(t *testing.T) { + // Setting this environment variable ensures TF core uses pluggable state storage during init. + // This is only temporary while PSS is experimental. + t.Setenv("TF_ENABLE_PLUGGABLE_STATE_STORAGE", "1") + + // Simulating an invalid state store that doesn't support writing state + stateStoreImpl := exampleCloudValidStateStore() + stateStoreImpl.WriteStateBytesFunc = nil + + r.UnitTest(t, r.TestCase{ + // State stores currently are only available in alpha releases or built from source + // with experiments enabled: `go install -ldflags="-X main.experimentsAllowed=yes" .` + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_15_0), + tfversion.SkipIfNotPrerelease(), + }, + ProtoV6ProviderFactories: map[string]func() (tfprotov6.ProviderServer, error){ + "examplecloud": providerserver.NewProviderServer(testprovider.Provider{ + StateStores: map[string]*testprovider.StateStore{ + "examplecloud_inmem": stateStoreImpl, + }, + }), + }, + Steps: []r.TestStep{ + { + StateStore: true, + Config: ` + terraform { + required_providers { + examplecloud = { + source = "registry.terraform.io/hashicorp/examplecloud" + } + } + state_store "examplecloud_inmem" { + provider "examplecloud" {} + } + } + `, + ExpectError: regexp.MustCompile(`After init, expected the "default" workspace to be created`), + }, + }, + }) +} diff --git a/helper/resource/statestore/terraform_backend_test.go b/helper/resource/statestore/terraform_backend_test.go new file mode 100644 index 000000000..5ba5cbf11 --- /dev/null +++ b/helper/resource/statestore/terraform_backend_test.go @@ -0,0 +1,71 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package statestore_test + +import ( + "regexp" + "testing" + + r "github.com/hashicorp/terraform-plugin-testing/helper/resource" + "github.com/hashicorp/terraform-plugin-testing/tfversion" +) + +// MAINTAINER NOTE: While the StateStore mode is designed to test state store implementations, it can +// also be used to test existing Terraform core backends, which we do in this test file just for +// additional verification of the test mode. + +func TestTerraformBackend_local(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + // StateStore test mode uses `terraform_data` + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_4_0), + }, + // MAINTAINER NOTE: Test steps won't run without a provider definition, so this is just + // needed to pass validation, as we're just testing Terraform core itself. + ExternalProviders: map[string]r.ExternalProvider{ + "terraform": {Source: "terraform.io/builtin/terraform"}, + }, + Steps: []r.TestStep{ + { + StateStore: true, + Config: ` + terraform { + backend "local" {} + } + `, + }, + }, + }) +} + +func TestTerraformBackend_local_empty_path_validation_error(t *testing.T) { + t.Parallel() + + r.UnitTest(t, r.TestCase{ + // StateStore test mode uses `terraform_data` + TerraformVersionChecks: []tfversion.TerraformVersionCheck{ + tfversion.SkipBelow(tfversion.Version1_4_0), + }, + // MAINTAINER NOTE: Test steps won't run without a provider definition, so this is just + // needed to pass validation, as we're just testing Terraform core itself. + ExternalProviders: map[string]r.ExternalProvider{ + "terraform": {Source: "terraform.io/builtin/terraform"}, + }, + Steps: []r.TestStep{ + { + StateStore: true, + Config: ` + terraform { + backend "local" { + path = "" + } + } + `, + ExpectError: regexp.MustCompile(`The "path" attribute value must not be empty.`), + }, + }, + }) +} diff --git a/helper/resource/testing.go b/helper/resource/testing.go index a9b21c245..00307e012 100644 --- a/helper/resource/testing.go +++ b/helper/resource/testing.go @@ -848,6 +848,18 @@ type TestStep struct { // If true, the test step will run the query command Query bool + + // The StateStore mode is used for testing state store implementations in a provider. The StateStore mode runs + // various Terraform CLI commands to ensure the configured state store operates in a manner expected by Terraform core. + // + // The StateStore mode expects state_store configuration to be provided using one of the Config, ConfigFile, + // or ConfigDirectory fields. + // + // StateStore mode tests that the provided state store: + // - Can be successfully initialized (validation and configuring) + // - Can read and write state + // - Supports workspaces (creating and deleting) + StateStore bool } // ConfigPlanChecks defines the different points in a Config TestStep when plan checks can be run. diff --git a/helper/resource/testing_new.go b/helper/resource/testing_new.go index 13e600019..a95297bcc 100644 --- a/helper/resource/testing_new.go +++ b/helper/resource/testing_new.go @@ -62,9 +62,18 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest protov6: c.ProtoV6ProviderFactories, } + // If any of the test steps used the StateStore mode and tested an error, make sure we don't execute any more commands with an invalid state store + var initializationErrorOccurred bool + defer func() { t.Helper() + // We can't retrieve the state because the backend/state store isn't fully initialized. + if initializationErrorOccurred { + wd.Close() + return + } + var statePreDestroy *terraform.State var err error err = runProviderCommand(ctx, t, wd, providers, func() error { @@ -394,6 +403,47 @@ func runNewTest(ctx context.Context, t testing.T, c TestCase, helper *plugintest continue } + if step.StateStore { + logging.HelperResourceTrace(ctx, "TestStep is StateStore mode") + + err := testStepNewStateStore(ctx, t, wd, step, providers, cfg) + if err != nil { + // Ensure the TestStep doesn't run any Terraform commands that expect the backend/state store to be initialized + initializationErrorOccurred = true + } + + if step.ExpectError != nil { + logging.HelperResourceDebug(ctx, "Checking TestStep ExpectError") + if err == nil { + logging.HelperResourceError(ctx, "Error running state store tests: expected an error but got none") + t.Fatalf("Step %d/%d error running state store tests: expected an error but got none", stepNumber, len(c.Steps)) + } + + if !step.ExpectError.MatchString(err.Error()) { + logging.HelperResourceError(ctx, fmt.Sprintf("Error running state store tests: expected an error with pattern (%s)", step.ExpectError.String()), + map[string]interface{}{logging.KeyError: err}, + ) + t.Fatalf("Step %d/%d error running state store tests, expected an error with pattern (%s), no match on: %s", stepNumber, len(c.Steps), step.ExpectError.String(), err) + } + } else { + if err != nil && c.ErrorCheck != nil { + logging.HelperResourceDebug(ctx, "Calling TestCase ErrorCheck") + err = c.ErrorCheck(err) + logging.HelperResourceDebug(ctx, "Called TestCase ErrorCheck") + } + if err != nil { + logging.HelperResourceError(ctx, "Error running state store tests", + map[string]interface{}{logging.KeyError: err}, + ) + t.Fatalf("Step %d/%d error running state store tests: %s", stepNumber, len(c.Steps), err) + } + } + + logging.HelperResourceDebug(ctx, "Finished TestStep") + + continue + } + if cfg != nil { logging.HelperResourceTrace(ctx, "TestStep is Config mode") diff --git a/helper/resource/testing_new_state_store.go b/helper/resource/testing_new_state_store.go new file mode 100644 index 000000000..1309056cf --- /dev/null +++ b/helper/resource/testing_new_state_store.go @@ -0,0 +1,291 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package resource + +import ( + "context" + "errors" + "fmt" + "slices" + + "github.com/hashicorp/terraform-exec/tfexec" + tfjson "github.com/hashicorp/terraform-json" + "github.com/hashicorp/terraform-plugin-testing/internal/plugintest" + "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/mitchellh/go-testing-interface" +) + +// testStepNewStateStore will run a series of Terraform commands with the goal of ensuring that the state store (defined in config): +// - Can be successfully initialized (validation and configuring) +// - Can read and write state +// - Supports workspaces (creating and deleting) +func testStepNewStateStore(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories, cfg teststep.Config) error { + t.Helper() + + err := wd.SetConfig(ctx, cfg, step.ConfigVariables) + if err != nil { + return fmt.Errorf("Error setting config: %w", err) + } + + // ----- Validate and configure the state store by running init + err = runProviderCommand(ctx, t, wd, providers, func() error { + return wd.Init(ctx) + }) + if err != nil { + return fmt.Errorf("Error running init: %w", err) + } + + // After initialization the only workspace created should be "default" + err = assertWorkspaces(ctx, t, wd, providers, []string{"default"}) + if err != nil { + return fmt.Errorf("After init, expected the \"default\" workspace to be created: %w", err) + } + + // ----- Create "foo" workspace + err = createAndAssertEmptyWorkspace(ctx, t, wd, providers, "foo") + if err != nil { + return fmt.Errorf("After creating a new workspace, the state should be empty: %w", err) + } + + // ----- Create "bar" workspace + err = createAndAssertEmptyWorkspace(ctx, t, wd, providers, "bar") + if err != nil { + return fmt.Errorf("After creating a new workspace, the state should be empty: %w", err) + } + + // ----- Apply test resources to the "bar" workspace and assert they are saved successfully in state + err = applyTestResources(ctx, t, wd, step, providers, cfg, "bar") + if err != nil { + return err + } + + // ----- Assert the "foo" workspace is still empty + err = assertEmptyWorkspace(ctx, t, wd, providers, "foo") + if err != nil { + return fmt.Errorf("After writing a resource to \"bar\" state, failed assertion: %s", err) + } + + // ----- Verify workspaces are "default", "foo" (created during this test), and "bar" (created during this test) + err = assertWorkspaces(ctx, t, wd, providers, []string{"bar", "default", "foo"}) + if err != nil { + return err + } + + // ----- Delete "bar" workspace + err = deleteWorkspace(ctx, t, wd, providers, "bar") + if err != nil { + return err + } + + // ----- Attempting to delete "default" workspace should return an error + err = runProviderCommand(ctx, t, wd, providers, func() error { + return wd.SelectWorkspace(ctx, "foo") + }) + if err != nil { + return fmt.Errorf("Error selecting \"foo\" workspace: %w", err) + } + err = runProviderCommand(ctx, t, wd, providers, func() error { + return wd.DeleteWorkspace(ctx, "default", tfexec.Force(true)) + }) + if err == nil { + return errors.New("Expected error when deleting \"default\" workspace") + } + + // ----- Recreate the "bar" workspace and assert it is empty (i.e. no left over artifacts) + err = createAndAssertEmptyWorkspace(ctx, t, wd, providers, "bar") + if err != nil { + return fmt.Errorf("After deleting, then recreating a new workspace, the state should be empty: %w", err) + } + + // ----- Delete "bar" workspace + err = deleteWorkspace(ctx, t, wd, providers, "bar") + if err != nil { + return err + } + + // ----- List workspaces and verify it's just "foo" and "default" + err = assertWorkspaces(ctx, t, wd, providers, []string{"default", "foo"}) + if err != nil { + return err + } + + // ----- Delete "foo" workspace + err = deleteWorkspace(ctx, t, wd, providers, "foo") + if err != nil { + return err + } + + // ----- List workspaces and verify it's just "default" (which we did not modify) + err = assertWorkspaces(ctx, t, wd, providers, []string{"default"}) + if err != nil { + return err + } + + return nil +} + +func assertWorkspaces(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, providers *providerFactories, expected []string) error { + t.Helper() + + var err error + actualWorkspaces := make([]string, 0) + err = runProviderCommand(ctx, t, wd, providers, func() error { + actualWorkspaces, err = wd.Workspaces(ctx) + return err + }) + if err != nil { + return fmt.Errorf("Error getting workspaces: %w", err) + } + + slices.Sort(expected) + slices.Sort(actualWorkspaces) + + if !slices.Equal(expected, actualWorkspaces) { + return fmt.Errorf("Expected workspaces to be %#v, got: %#v", expected, actualWorkspaces) + } + + return nil +} + +func createAndAssertEmptyWorkspace(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, providers *providerFactories, workspace string) error { + t.Helper() + + err := runProviderCommand(ctx, t, wd, providers, func() error { + return wd.CreateWorkspace(ctx, workspace) + }) + if err != nil { + return fmt.Errorf("Error creating %q workspace: %w", workspace, err) + } + + return assertEmptyWorkspace(ctx, t, wd, providers, workspace) +} + +func assertEmptyWorkspace(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, providers *providerFactories, workspace string) error { + t.Helper() + + err := runProviderCommand(ctx, t, wd, providers, func() error { + return wd.SelectWorkspace(ctx, workspace) + }) + if err != nil { + return fmt.Errorf("Error selecting %q workspace: %w", workspace, err) + } + + var stateObj *tfjson.State + err = runProviderCommand(ctx, t, wd, providers, func() error { + stateObj, err = wd.State(ctx) + return err + }) + if err != nil { + return fmt.Errorf("Error retrieving %q state: %w", workspace, err) + } + + if stateObj.Values != nil && stateObj.Values.RootModule != nil && len(stateObj.Values.RootModule.Resources) > 0 { + return fmt.Errorf("Expected %q state to be empty. Found %d resources.", workspace, len(stateObj.Values.RootModule.Resources)) + } + + return nil +} + +func deleteWorkspace(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, providers *providerFactories, workspace string) error { + t.Helper() + + // Select "default" workspace so we can delete the requested workspace + err := runProviderCommand(ctx, t, wd, providers, func() error { + return wd.SelectWorkspace(ctx, "default") + }) + if err != nil { + return fmt.Errorf("Error selecting \"default\" workspace: %w", err) + } + err = runProviderCommand(ctx, t, wd, providers, func() error { + return wd.DeleteWorkspace(ctx, workspace, tfexec.Force(true)) + }) + if err != nil { + return fmt.Errorf("Error deleting %q workspace: %w", workspace, err) + } + + return nil +} + +// This is the primary place that state storage is being tested, we create two test resources (using the built-in terraform_data resource) with +// two different "terraform apply" commands, then check the state for their presence. +func applyTestResources(ctx context.Context, t testing.T, wd *plugintest.WorkingDir, step TestStep, providers *providerFactories, cfg teststep.Config, workspace string) error { + t.Helper() + + err := runProviderCommand(ctx, t, wd, providers, func() error { + return wd.SelectWorkspace(ctx, workspace) + }) + if err != nil { + return fmt.Errorf("Error selecting %q workspace: %w", workspace, err) + } + + // ----- Apply test resource 1 to workspace + expectedOutput := "this resource was injected by terraform-plugin-testing" + testResourceCfg1 := fmt.Sprintf(` + resource "terraform_data" "tf_plugin_testing_resource_1" { + input = %q + }`, expectedOutput) + + cfg = cfg.Append(testResourceCfg1) + err = wd.SetConfig(ctx, cfg, step.ConfigVariables) + if err != nil { + return fmt.Errorf("Error setting config: %w", err) + } + + err = runProviderCommand(ctx, t, wd, providers, func() error { + return wd.Apply(ctx) + }) + if err != nil { + return fmt.Errorf("Error creating test resource in %q workspace: %w", workspace, err) + } + + // ----- Apply test resource 2 to workspace + testResourceCfg2 := fmt.Sprintf(` + resource "terraform_data" "tf_plugin_testing_resource_2" { + input = %q + }`, expectedOutput) + + cfg = cfg.Append(testResourceCfg2) + err = wd.SetConfig(ctx, cfg, step.ConfigVariables) + if err != nil { + return fmt.Errorf("Error setting config: %w", err) + } + + err = runProviderCommand(ctx, t, wd, providers, func() error { + return wd.Apply(ctx) + }) + if err != nil { + return fmt.Errorf("Error creating test resource in %q workspace: %w", workspace, err) + } + + // ----- Check if the resources exist in the state + var stateObj *tfjson.State + err = runProviderCommand(ctx, t, wd, providers, func() error { + stateObj, err = wd.State(ctx) + return err + }) + if err != nil { + return fmt.Errorf("Error retrieving %q state: %w", workspace, err) + } + + checkOutput := statecheck.ExpectKnownValue("terraform_data.tf_plugin_testing_resource_1", tfjsonpath.New("output"), knownvalue.StringExact(expectedOutput)) + checkResp := statecheck.CheckStateResponse{} + + checkOutput.CheckState(ctx, statecheck.CheckStateRequest{State: stateObj}, &checkResp) + if checkResp.Error != nil { + return fmt.Errorf("After writing a test resource instance object to %q state and re-reading it, the object has vanished: %w", workspace, checkResp.Error) + } + + checkOutput = statecheck.ExpectKnownValue("terraform_data.tf_plugin_testing_resource_2", tfjsonpath.New("output"), knownvalue.StringExact(expectedOutput)) + checkResp = statecheck.CheckStateResponse{} + + checkOutput.CheckState(ctx, statecheck.CheckStateRequest{State: stateObj}, &checkResp) + if checkResp.Error != nil { + return fmt.Errorf("After writing a test resource instance object to %q state and re-reading it, the object has vanished: %w", workspace, checkResp.Error) + } + + return nil +} diff --git a/helper/resource/teststep_validate_test.go b/helper/resource/teststep_validate_test.go index f94f1a4a9..fd66d9b8b 100644 --- a/helper/resource/teststep_validate_test.go +++ b/helper/resource/teststep_validate_test.go @@ -503,6 +503,13 @@ func TestTestStepValidate(t *testing.T) { testStepValidateRequest: testStepValidateRequest{TestCaseHasProviders: true}, expectedError: errors.New("TestStep RefreshPlanChecks.PostRefresh must only be specified with RefreshState"), }, + "state-store-mode-missing-config": { + testStep: TestStep{ + StateStore: true, + }, + testStepValidateRequest: testStepValidateRequest{}, + expectedError: fmt.Errorf("TestStep missing Config or ConfigDirectory or ConfigFile or ImportState or RefreshState"), + }, } for name, test := range tests { diff --git a/internal/plugintest/working_dir.go b/internal/plugintest/working_dir.go index 9083ba648..0f3e98c22 100644 --- a/internal/plugintest/working_dir.go +++ b/internal/plugintest/working_dir.go @@ -563,3 +563,45 @@ func (wd *WorkingDir) Query(ctx context.Context) ([]tfjson.LogMsg, error) { return messages, nil } + +func (wd *WorkingDir) Workspaces(ctx context.Context) ([]string, error) { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI workspace list command") + + workspaces, _, err := wd.tf.WorkspaceList(context.Background(), tfexec.Reattach(wd.reattachInfo)) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI workspace list command") + + return workspaces, err +} + +func (wd *WorkingDir) CreateWorkspace(ctx context.Context, workspace string) error { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI workspace new command") + + err := wd.tf.WorkspaceNew(context.Background(), workspace, tfexec.Reattach(wd.reattachInfo)) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI workspace new command") + + return err +} + +func (wd *WorkingDir) SelectWorkspace(ctx context.Context, workspace string) error { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI workspace select command") + + err := wd.tf.WorkspaceSelect(context.Background(), workspace, tfexec.Reattach(wd.reattachInfo)) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI workspace select command") + + return err +} + +func (wd *WorkingDir) DeleteWorkspace(ctx context.Context, workspace string, opts ...tfexec.WorkspaceDeleteCmdOption) error { + logging.HelperResourceTrace(ctx, "Calling Terraform CLI workspace delete command") + + opts = append(opts, tfexec.Reattach(wd.reattachInfo)) + + err := wd.tf.WorkspaceDelete(context.Background(), workspace, opts...) + + logging.HelperResourceTrace(ctx, "Called Terraform CLI workspace delete command") + + return err +} diff --git a/internal/testing/testprovider/provider.go b/internal/testing/testprovider/provider.go index b72c35327..f02f07020 100644 --- a/internal/testing/testprovider/provider.go +++ b/internal/testing/testprovider/provider.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/list" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/provider" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/statestore" ) var _ provider.Provider = Provider{} @@ -22,6 +23,7 @@ type Provider struct { DataSources map[string]DataSource ListResources map[string]ListResource Resources map[string]Resource + StateStores map[string]*StateStore SchemaResponse *provider.SchemaResponse StopResponse *provider.StopResponse ValidateConfigResponse *provider.ValidateConfigResponse @@ -63,6 +65,16 @@ func (p Provider) ResourcesMap() map[string]resource.Resource { return resources } +func (p Provider) StateStoresMap() map[string]statestore.StateStore { + stateStores := make(map[string]statestore.StateStore, len(p.StateStores)) + + for typeName, s := range p.StateStores { + stateStores[typeName] = s + } + + return stateStores +} + func (p Provider) Stop(ctx context.Context, req provider.StopRequest, resp *provider.StopResponse) { if p.StopResponse != nil { resp.Error = p.StopResponse.Error diff --git a/internal/testing/testprovider/statestore.go b/internal/testing/testprovider/statestore.go new file mode 100644 index 000000000..b77348f14 --- /dev/null +++ b/internal/testing/testprovider/statestore.go @@ -0,0 +1,95 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package testprovider + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/statestore" +) + +var _ statestore.StateStore = &StateStore{} + +type StateStore struct { + configuredChunkSize int64 + + SchemaResponse *statestore.SchemaResponse + ConfigureResponse *statestore.ConfigureResponse + ValidateConfigResponse *statestore.ValidateConfigResponse + + // Declaring a mock state store is slightly more complicated then other types since the implementation is likely + // needed to be stateful to work with multiple terraform commands. + GetStatesFunc func(context.Context, statestore.GetStatesRequest, *statestore.GetStatesResponse) + DeleteStateFunc func(context.Context, statestore.DeleteStateRequest, *statestore.DeleteStateResponse) + LockStateFunc func(context.Context, statestore.LockStateRequest, *statestore.LockStateResponse) + UnlockStateFunc func(context.Context, statestore.UnlockStateRequest, *statestore.UnlockStateResponse) + ReadStateBytesFunc func(context.Context, statestore.ReadStateBytesRequest, *statestore.ReadStateBytesResponse) + WriteStateBytesFunc func(context.Context, statestore.WriteStateBytesRequest, *statestore.WriteStateBytesResponse) +} + +func (s *StateStore) Schema(ctx context.Context, req statestore.SchemaRequest, resp *statestore.SchemaResponse) { + if s.SchemaResponse != nil { + resp.Diagnostics = s.SchemaResponse.Diagnostics + resp.Schema = s.SchemaResponse.Schema + } +} + +func (s *StateStore) Configure(ctx context.Context, req statestore.ConfigureRequest, resp *statestore.ConfigureResponse) { + if s.ConfigureResponse != nil { + resp.Diagnostics = s.ConfigureResponse.Diagnostics + + if s.ConfigureResponse.ServerCapabilities != nil { + resp.ServerCapabilities = s.ConfigureResponse.ServerCapabilities + } + } + + // Store configured chunk size + s.configuredChunkSize = resp.ServerCapabilities.ChunkSize +} + +func (s *StateStore) ConfiguredChunkSize() int64 { + return s.configuredChunkSize +} + +func (s *StateStore) ValidateConfig(ctx context.Context, req statestore.ValidateConfigRequest, resp *statestore.ValidateConfigResponse) { + if s.ValidateConfigResponse != nil { + resp.Diagnostics = s.ValidateConfigResponse.Diagnostics + } +} + +func (s *StateStore) GetStates(ctx context.Context, req statestore.GetStatesRequest, resp *statestore.GetStatesResponse) { + if s.GetStatesFunc != nil { + s.GetStatesFunc(ctx, req, resp) + } +} + +func (s *StateStore) DeleteState(ctx context.Context, req statestore.DeleteStateRequest, resp *statestore.DeleteStateResponse) { + if s.DeleteStateFunc != nil { + s.DeleteStateFunc(ctx, req, resp) + } +} + +func (s *StateStore) LockState(ctx context.Context, req statestore.LockStateRequest, resp *statestore.LockStateResponse) { + if s.LockStateFunc != nil { + s.LockStateFunc(ctx, req, resp) + } +} + +func (s *StateStore) UnlockState(ctx context.Context, req statestore.UnlockStateRequest, resp *statestore.UnlockStateResponse) { + if s.UnlockStateFunc != nil { + s.UnlockStateFunc(ctx, req, resp) + } +} + +func (s *StateStore) ReadStateBytes(ctx context.Context, req statestore.ReadStateBytesRequest, resp *statestore.ReadStateBytesResponse) { + if s.ReadStateBytesFunc != nil { + s.ReadStateBytesFunc(ctx, req, resp) + } +} + +func (s *StateStore) WriteStateBytes(ctx context.Context, req statestore.WriteStateBytesRequest, resp *statestore.WriteStateBytesResponse) { + if s.WriteStateBytesFunc != nil { + s.WriteStateBytesFunc(ctx, req, resp) + } +} diff --git a/internal/testing/testsdk/provider/provider.go b/internal/testing/testsdk/provider/provider.go index 93206f2b7..1d951f36a 100644 --- a/internal/testing/testsdk/provider/provider.go +++ b/internal/testing/testsdk/provider/provider.go @@ -11,6 +11,7 @@ import ( "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/datasource" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/list" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/statestore" ) type Provider interface { @@ -18,6 +19,7 @@ type Provider interface { DataSourcesMap() map[string]datasource.DataSource ListResourcesMap() map[string]list.ListResource ResourcesMap() map[string]resource.Resource + StateStoresMap() map[string]statestore.StateStore Schema(context.Context, SchemaRequest, *SchemaResponse) Stop(context.Context, StopRequest, *StopResponse) ValidateConfig(context.Context, ValidateConfigRequest, *ValidateConfigResponse) diff --git a/internal/testing/testsdk/providerserver/providerserver.go b/internal/testing/testsdk/providerserver/providerserver.go index ed825ccf2..1334024cf 100644 --- a/internal/testing/testsdk/providerserver/providerserver.go +++ b/internal/testing/testsdk/providerserver/providerserver.go @@ -4,10 +4,13 @@ package providerserver import ( + "bytes" "context" "errors" "fmt" + "io" "iter" + "slices" "github.com/hashicorp/terraform-plugin-go/tfprotov6" "github.com/hashicorp/terraform-plugin-go/tftypes" @@ -16,12 +19,11 @@ import ( "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/list" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/provider" "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/resource" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/statestore" ) var _ tfprotov6.ProviderServer = ProviderServer{} -// var _ tfprotov6.ProviderServerWithListResource = ProviderServer{} - // NewProviderServer returns a lightweight protocol version 6 provider server // for consumption with ProtoV6ProviderFactories. func NewProviderServer(p provider.Provider) func() (tfprotov6.ProviderServer, error) { @@ -319,6 +321,7 @@ func (s ProviderServer) GetProviderSchema(ctx context.Context, req *tfprotov6.Ge ListResourceSchemas: map[string]*tfprotov6.Schema{}, Provider: providerResp.Schema, ResourceSchemas: map[string]*tfprotov6.Schema{}, + StateStoreSchemas: map[string]*tfprotov6.Schema{}, ServerCapabilities: &tfprotov6.ServerCapabilities{ PlanDestroy: true, }, @@ -357,6 +360,17 @@ func (s ProviderServer) GetProviderSchema(ctx context.Context, req *tfprotov6.Ge resp.ResourceSchemas[typeName] = schemaResp.Schema } + for typeName, s := range s.Provider.StateStoresMap() { + schemaReq := statestore.SchemaRequest{} + schemaResp := &statestore.SchemaResponse{} + + s.Schema(ctx, schemaReq, schemaResp) + + resp.Diagnostics = append(resp.Diagnostics, schemaResp.Diagnostics...) + + resp.StateStoreSchemas[typeName] = schemaResp.Schema + } + return resp, nil } @@ -912,7 +926,6 @@ func (s ProviderServer) UpgradeResourceState(ctx context.Context, req *tfprotov6 } func (s ProviderServer) UpgradeResourceIdentity(context.Context, *tfprotov6.UpgradeResourceIdentityRequest) (*tfprotov6.UpgradeResourceIdentityResponse, error) { - // TODO: This isn't currently being used by the testing framework provider, so no need to implement it until then. return nil, errors.New("UpgradeResourceIdentity is not currently implemented in testprovider") } @@ -1231,3 +1244,322 @@ func processListResult(req list.ListRequest, result list.ListResult) tfprotov6.L return listResourceResult } + +func (s ProviderServer) ConfigureStateStore(ctx context.Context, req *tfprotov6.ConfigureStateStoreRequest) (*tfprotov6.ConfigureStateStoreResponse, error) { + resp := &tfprotov6.ConfigureStateStoreResponse{} + + store, diag := ProviderStateStore(s.Provider, req.TypeName) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + schemaReq := statestore.SchemaRequest{} + schemaResp := &statestore.SchemaResponse{} + + store.Schema(ctx, schemaReq, schemaResp) + + resp.Diagnostics = schemaResp.Diagnostics + + if len(resp.Diagnostics) > 0 { + return resp, nil + } + + config, diag := DynamicValueToValue(schemaResp.Schema, req.Config) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + configureReq := statestore.ConfigureRequest{ + Config: config, + ClientCapabilities: req.Capabilities, + } + configureResp := &statestore.ConfigureResponse{ + // Round-trip the core-provided chunk size as the default + ServerCapabilities: &tfprotov6.StateStoreServerCapabilities{ + ChunkSize: req.Capabilities.ChunkSize, + }, + } + + store.Configure(ctx, configureReq, configureResp) + + resp.Diagnostics = configureResp.Diagnostics + resp.Capabilities = configureResp.ServerCapabilities + + return resp, nil +} + +func (s ProviderServer) ValidateStateStoreConfig(ctx context.Context, req *tfprotov6.ValidateStateStoreConfigRequest) (*tfprotov6.ValidateStateStoreConfigResponse, error) { + resp := &tfprotov6.ValidateStateStoreConfigResponse{} + + store, diag := ProviderStateStore(s.Provider, req.TypeName) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + schemaReq := statestore.SchemaRequest{} + schemaResp := &statestore.SchemaResponse{} + + store.Schema(ctx, schemaReq, schemaResp) + + resp.Diagnostics = schemaResp.Diagnostics + + if len(resp.Diagnostics) > 0 { + return resp, nil + } + + config, diag := DynamicValueToValue(schemaResp.Schema, req.Config) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + validateReq := statestore.ValidateConfigRequest{ + Config: config, + } + validateResp := &statestore.ValidateConfigResponse{} + + store.ValidateConfig(ctx, validateReq, validateResp) + + resp.Diagnostics = validateResp.Diagnostics + + return resp, nil +} + +func (s ProviderServer) GetStates(ctx context.Context, req *tfprotov6.GetStatesRequest) (*tfprotov6.GetStatesResponse, error) { + resp := &tfprotov6.GetStatesResponse{} + + store, diag := ProviderStateStore(s.Provider, req.TypeName) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + getStatesReq := statestore.GetStatesRequest{} + getStatesResp := &statestore.GetStatesResponse{} + + store.GetStates(ctx, getStatesReq, getStatesResp) + + resp.Diagnostics = getStatesResp.Diagnostics + resp.StateIDs = getStatesResp.StateIDs + + return resp, nil +} + +func (s ProviderServer) DeleteState(ctx context.Context, req *tfprotov6.DeleteStateRequest) (*tfprotov6.DeleteStateResponse, error) { + resp := &tfprotov6.DeleteStateResponse{} + + store, diag := ProviderStateStore(s.Provider, req.TypeName) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + deleteStateReq := statestore.DeleteStateRequest{ + StateID: req.StateID, + } + deleteStateResp := &statestore.DeleteStateResponse{} + + store.DeleteState(ctx, deleteStateReq, deleteStateResp) + + resp.Diagnostics = deleteStateResp.Diagnostics + + return resp, nil +} + +func (s ProviderServer) LockState(ctx context.Context, req *tfprotov6.LockStateRequest) (*tfprotov6.LockStateResponse, error) { + resp := &tfprotov6.LockStateResponse{} + + store, diag := ProviderStateStore(s.Provider, req.TypeName) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + lockStateReq := statestore.LockStateRequest{ + StateID: req.StateID, + Operation: req.Operation, + } + lockStateResp := &statestore.LockStateResponse{} + + store.LockState(ctx, lockStateReq, lockStateResp) + + resp.Diagnostics = lockStateResp.Diagnostics + resp.LockID = lockStateResp.LockID + + return resp, nil +} + +func (s ProviderServer) UnlockState(ctx context.Context, req *tfprotov6.UnlockStateRequest) (*tfprotov6.UnlockStateResponse, error) { + resp := &tfprotov6.UnlockStateResponse{} + + store, diag := ProviderStateStore(s.Provider, req.TypeName) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + lockStateReq := statestore.UnlockStateRequest{ + StateID: req.StateID, + LockID: req.LockID, + } + lockStateResp := &statestore.UnlockStateResponse{} + + store.UnlockState(ctx, lockStateReq, lockStateResp) + + resp.Diagnostics = lockStateResp.Diagnostics + + return resp, nil +} + +func (s ProviderServer) ReadStateBytes(ctx context.Context, req *tfprotov6.ReadStateBytesRequest) (*tfprotov6.ReadStateBytesStream, error) { + resp := &tfprotov6.ReadStateBytesStream{} + + store, diag := ProviderStateStore(s.Provider, req.TypeName) + + if diag != nil { + resp.Chunks = slices.Values([]tfprotov6.ReadStateByteChunk{{Diagnostics: []*tfprotov6.Diagnostic{diag}}}) + return resp, nil + } + + readStateBytesReq := statestore.ReadStateBytesRequest{ + StateID: req.StateID, + } + readStateBytesResp := &statestore.ReadStateBytesResponse{} + + store.ReadStateBytes(ctx, readStateBytesReq, readStateBytesResp) + + if len(readStateBytesResp.Diagnostics) > 0 { + resp.Chunks = slices.Values([]tfprotov6.ReadStateByteChunk{{Diagnostics: readStateBytesResp.Diagnostics}}) + return resp, nil + } + + chunkSize := store.ConfiguredChunkSize() + reader := bytes.NewReader(readStateBytesResp.StateBytes) + totalLength := reader.Size() + rangeStart := 0 + + resp.Chunks = func(yield func(tfprotov6.ReadStateByteChunk) bool) { + for { + readBytes := make([]byte, chunkSize) + byteCount, err := reader.Read(readBytes) + if err != nil && !errors.Is(err, io.EOF) { + chunkWithDiag := tfprotov6.ReadStateByteChunk{ + Diagnostics: []*tfprotov6.Diagnostic{ + { + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Error reading state", + Detail: fmt.Sprintf("An unexpected error occurred while reading state data for %s: %s", + req.StateID, + err, + ), + }, + }, + } + if !yield(chunkWithDiag) { + return + } + } + + if byteCount == 0 { + // We've sent all of the bytes in the reader + return + } + + chunk := tfprotov6.ReadStateByteChunk{ + StateByteChunk: tfprotov6.StateByteChunk{ + Bytes: readBytes[:byteCount], + TotalLength: totalLength, + Range: tfprotov6.StateByteRange{ + Start: int64(rangeStart), + End: int64(rangeStart + byteCount), + }, + }, + } + if !yield(chunk) { + return + } + + rangeStart += byteCount + } + } + + return resp, nil +} + +func (s ProviderServer) WriteStateBytes(ctx context.Context, req *tfprotov6.WriteStateBytesStream) (*tfprotov6.WriteStateBytesResponse, error) { + resp := &tfprotov6.WriteStateBytesResponse{} + + var stateBuffer bytes.Buffer + var typeName string + var stateId string + + for chunk, diags := range req.Chunks { + if len(diags) > 0 { + resp.Diagnostics = append(resp.Diagnostics, diags...) + return resp, nil + } + + if chunk.Meta != nil { + typeName = chunk.Meta.TypeName + stateId = chunk.Meta.StateID + } + + _, err := stateBuffer.Write(chunk.Bytes) + if err != nil { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Error writing state", + Detail: fmt.Sprintf("An unexpected error occurred receieving state data from Terraform: %s", err), + }) + return resp, nil + } + } + + if stateBuffer.Len() == 0 { + resp.Diagnostics = append(resp.Diagnostics, &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Error writing state", + Detail: "No state data was received from Terraform. This is a bug and should be reported.", + }) + return resp, nil + } + + store, diag := ProviderStateStore(s.Provider, typeName) + + if diag != nil { + resp.Diagnostics = append(resp.Diagnostics, diag) + + return resp, nil + } + + writeStateBytesReq := statestore.WriteStateBytesRequest{ + StateID: stateId, + StateBytes: stateBuffer.Bytes(), + } + writeStateBytesResp := &statestore.WriteStateBytesResponse{} + + store.WriteStateBytes(ctx, writeStateBytesReq, writeStateBytesResp) + + resp.Diagnostics = writeStateBytesResp.Diagnostics + + return resp, nil +} diff --git a/internal/testing/testsdk/providerserver/statestores.go b/internal/testing/testsdk/providerserver/statestores.go new file mode 100644 index 000000000..5b16e8eea --- /dev/null +++ b/internal/testing/testsdk/providerserver/statestores.go @@ -0,0 +1,24 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package providerserver + +import ( + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/provider" + "github.com/hashicorp/terraform-plugin-testing/internal/testing/testsdk/statestore" +) + +func ProviderStateStore(p provider.Provider, typeName string) (statestore.StateStore, *tfprotov6.Diagnostic) { + s, ok := p.StateStoresMap()[typeName] + + if !ok { + return nil, &tfprotov6.Diagnostic{ + Severity: tfprotov6.DiagnosticSeverityError, + Summary: "Missing State Store Type", + Detail: "The provider does not define the state store type: " + typeName, + } + } + + return s, nil +} diff --git a/internal/testing/testsdk/statestore/statestore.go b/internal/testing/testsdk/statestore/statestore.go new file mode 100644 index 000000000..78a583007 --- /dev/null +++ b/internal/testing/testsdk/statestore/statestore.go @@ -0,0 +1,105 @@ +// Copyright IBM Corp. 2014, 2025 +// SPDX-License-Identifier: MPL-2.0 + +package statestore + +import ( + "context" + + "github.com/hashicorp/terraform-plugin-go/tfprotov6" + "github.com/hashicorp/terraform-plugin-go/tftypes" +) + +type StateStore interface { + Schema(context.Context, SchemaRequest, *SchemaResponse) + Configure(context.Context, ConfigureRequest, *ConfigureResponse) + ValidateConfig(context.Context, ValidateConfigRequest, *ValidateConfigResponse) + GetStates(context.Context, GetStatesRequest, *GetStatesResponse) + DeleteState(context.Context, DeleteStateRequest, *DeleteStateResponse) + LockState(context.Context, LockStateRequest, *LockStateResponse) + UnlockState(context.Context, UnlockStateRequest, *UnlockStateResponse) + + // For ease-of-use, the streaming of chunk data is handled in the provider server for reading/writing + ReadStateBytes(context.Context, ReadStateBytesRequest, *ReadStateBytesResponse) + WriteStateBytes(context.Context, WriteStateBytesRequest, *WriteStateBytesResponse) + + // This isn't a GRPC call, but it allows the implementation to define the chunk size while the provider server owns the actual chunking logic. + ConfiguredChunkSize() int64 +} + +type SchemaRequest struct{} + +type SchemaResponse struct { + Diagnostics []*tfprotov6.Diagnostic + Schema *tfprotov6.Schema +} + +type ConfigureRequest struct { + Config tftypes.Value + ClientCapabilities *tfprotov6.ConfigureStateStoreClientCapabilities +} + +type ConfigureResponse struct { + Diagnostics []*tfprotov6.Diagnostic + ServerCapabilities *tfprotov6.StateStoreServerCapabilities +} + +type ValidateConfigRequest struct { + Config tftypes.Value +} + +type ValidateConfigResponse struct { + Diagnostics []*tfprotov6.Diagnostic +} + +type GetStatesRequest struct{} + +type GetStatesResponse struct { + StateIDs []string + Diagnostics []*tfprotov6.Diagnostic +} + +type DeleteStateRequest struct { + StateID string +} + +type DeleteStateResponse struct { + Diagnostics []*tfprotov6.Diagnostic +} + +type LockStateRequest struct { + StateID string + Operation string +} + +type LockStateResponse struct { + LockID string + Diagnostics []*tfprotov6.Diagnostic +} + +type UnlockStateRequest struct { + StateID string + LockID string +} + +type UnlockStateResponse struct { + Diagnostics []*tfprotov6.Diagnostic +} + +type ReadStateBytesRequest struct { + StateID string +} + +type ReadStateBytesResponse struct { + StateBytes []byte + Diagnostics []*tfprotov6.Diagnostic +} + +type WriteStateBytesRequest struct { + StateID string + StateBytes []byte +} + +type WriteStateBytesResponse struct { + Diagnostics []*tfprotov6.Diagnostic +} diff --git a/tfversion/versions.go b/tfversion/versions.go index 55c0b12c9..ee204fad0 100644 --- a/tfversion/versions.go +++ b/tfversion/versions.go @@ -41,4 +41,5 @@ var ( Version1_12_0 *version.Version = version.Must(version.NewVersion("1.12.0")) Version1_13_0 *version.Version = version.Must(version.NewVersion("1.13.0")) Version1_14_0 *version.Version = version.Must(version.NewVersion("1.14.0")) + Version1_15_0 *version.Version = version.Must(version.NewVersion("1.15.0")) )