Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
60e8997
Allow the builtin terraform provider to contain multiple PSS implemen…
SarahFrench Oct 20, 2025
306d896
Make it easier to use provider.Interface as interface for state store…
SarahFrench Oct 20, 2025
9f104fa
Add initial implementation of inmem state store to the builtin terraf…
SarahFrench Oct 20, 2025
b79de8d
Add method for getting an inmem store instance that has default works…
SarahFrench Oct 20, 2025
f54f95b
Copy some tests from the inmem backend and use for the inmem state st…
SarahFrench Oct 20, 2025
a3ffebd
Simplify code to avoid map of state stores, for now
SarahFrench Oct 21, 2025
c4e0274
WIP simple
SarahFrench Oct 22, 2025
759df39
Implement inmem state store in provider-simple-v6, remove from builti…
SarahFrench Oct 23, 2025
c6cea63
Move PSS chunking-related constants into the `pluggable` package, so …
SarahFrench Oct 23, 2025
320fa96
Implement PSS-related methods in grpcwrap package
SarahFrench Oct 24, 2025
992eb53
Update e2e test - works as expected but is blocked at apply step by o…
SarahFrench Oct 24, 2025
eeaaac8
Fix issues in test fixture, rename it
SarahFrench Oct 24, 2025
099b206
Update test - remove steps that are impossible to perform with inmem …
SarahFrench Oct 24, 2025
40b9930
Stop gating the inMem state store behind TF_ACC in the simple6 provider
SarahFrench Oct 24, 2025
4705e52
Skip E2E test for PSS unless experiments enabled
SarahFrench Oct 24, 2025
bd0fa59
Enable experiments in E2E tests in automation
SarahFrench Oct 24, 2025
5f0581f
Add missing space to error message text
SarahFrench Oct 24, 2025
b986709
Fix TF_TEST_EXPERIMENT => TF_TEST_EXPERIMENTS
SarahFrench Oct 30, 2025
2146307
Ensure state stores are configured with a suggested chunk size from Core
SarahFrench Oct 30, 2025
0cc10c5
Small changes to inmem E2E test
SarahFrench Oct 30, 2025
9979fe1
Add filesystem state store (no locking) and E2E test
SarahFrench Oct 30, 2025
316d5b5
Replace ioutil.ReadDir with os.ReadDir
SarahFrench Nov 3, 2025
9088fe5
fix: Pass through id of state to delete in grpcwrap's DeleteState met…
SarahFrench Nov 6, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ jobs:

- name: "End-to-end tests"
run: |
TF_ACC=1 go test -v ./internal/command/e2etest
TF_TEST_EXPERIMENTS=1 TF_ACC=1 go test -v ./internal/command/e2etest

consistency-checks:
name: "Code Consistency Checks"
Expand Down
3 changes: 1 addition & 2 deletions internal/backend/local/backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import (
"context"
"errors"
"fmt"
"io/ioutil"
"log"
"os"
"path/filepath"
Expand Down Expand Up @@ -207,7 +206,7 @@ func (b *Local) Workspaces() ([]string, tfdiags.Diagnostics) {
// the listing always start with "default"
envs := []string{backend.DefaultStateName}

entries, err := ioutil.ReadDir(b.stateWorkspaceDir())
entries, err := os.ReadDir(b.stateWorkspaceDir())
// no error if there's no envs configured
if os.IsNotExist(err) {
return envs, nil
Expand Down
18 changes: 18 additions & 0 deletions internal/backend/pluggable/chunks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
// Copyright (c) HashiCorp, Inc.
// SPDX-License-Identifier: BUSL-1.1

package pluggable

const (
// DefaultStateStoreChunkSize is the default chunk size proposed
// to the provider.
// This can be tweaked but should provide reasonable performance
// trade-offs for average network conditions and state file sizes.
DefaultStateStoreChunkSize int64 = 8 << 20 // 8 MB

// MaxStateStoreChunkSize is the highest chunk size provider may choose
// which we still consider reasonable/safe.
// This reflects terraform-plugin-go's max. RPC message size of 256MB
// and leaves plenty of space for other variable data like diagnostics.
MaxStateStoreChunkSize int64 = 128 << 20 // 128 MB
)
3 changes: 3 additions & 0 deletions internal/backend/pluggable/pluggable.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,9 @@ func (p *Pluggable) Configure(config cty.Value) tfdiags.Diagnostics {
req := providers.ConfigureStateStoreRequest{
TypeName: p.typeName,
Config: config,
Capabilities: providers.StateStoreClientCapabilities{
ChunkSize: DefaultStateStoreChunkSize,
},
Comment on lines +108 to +110
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I realised that we never sent the default chunk size data from Core to the provider at the start of chunk size negotiation 😬

}
resp := p.provider.ConfigureStateStore(req)
return resp.Diagnostics
Expand Down
2 changes: 1 addition & 1 deletion internal/backend/remote-state/inmem/backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ func TestBackendLocked(t *testing.T) {
backend.TestBackendStateLocks(t, b1, b2)
}

// use the this backen to test the remote.State implementation
// use this backend to test the remote.State implementation
func TestRemoteState(t *testing.T) {
defer Reset()
b := backend.TestBackendConfig(t, New(), hcl.EmptyBody())
Expand Down
2 changes: 2 additions & 0 deletions internal/builtin/providers/terraform/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ import (
// Provider is an implementation of providers.Interface
type Provider struct{}

var _ providers.Interface = &Provider{}

// NewProvider returns a new terraform provider
func NewProvider() providers.Interface {
return &Provider{}
Expand Down
2 changes: 1 addition & 1 deletion internal/command/arguments/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ func ParseInit(args []string, experimentsEnabled bool) (*Init, tfdiags.Diagnosti
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Cannot use -enable-pluggable-state-storage-experiment flag without experiments enabled",
"Terraform cannot use the-enable-pluggable-state-storage-experiment flag (or TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable) unless experiments are enabled.",
"Terraform cannot use the -enable-pluggable-state-storage-experiment flag (or TF_ENABLE_PLUGGABLE_STATE_STORAGE environment variable) unless experiments are enabled.",
))
}
if !init.CreateDefaultWorkspace {
Expand Down
149 changes: 149 additions & 0 deletions internal/command/e2etest/primary_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
package e2etest

import (
"os"
"path/filepath"
"reflect"
"sort"
Expand All @@ -12,7 +13,9 @@ import (

"github.com/davecgh/go-spew/spew"
"github.com/hashicorp/terraform/internal/e2e"
"github.com/hashicorp/terraform/internal/getproviders"
"github.com/hashicorp/terraform/internal/plans"
"github.com/hashicorp/terraform/internal/states/statefile"
"github.com/zclconf/go-cty/cty"
)

Expand Down Expand Up @@ -230,3 +233,149 @@ func TestPrimaryChdirOption(t *testing.T) {
t.Errorf("incorrect destroy tally; want 0 destroyed:\n%s", stdout)
}
}

// Requires TF_TEST_EXPERIMENTS to be set in the environment
func TestPrimary_stateStore(t *testing.T) {
if v := os.Getenv("TF_TEST_EXPERIMENTS"); v == "" {
t.Skip("can't run without enabling experiments in the executable terraform binary, enable with TF_TEST_EXPERIMENTS=1")
}

if !canRunGoBuild {
// We're running in a separate-build-then-run context, so we can't
// currently execute this test which depends on being able to build
// new executable at runtime.
//
// (See the comment on canRunGoBuild's declaration for more information.)
t.Skip("can't run without building a new provider executable")
}
t.Parallel()

tf := e2e.NewBinary(t, terraformBin, "testdata/full-workflow-with-state-store-fs")

// In order to test integration with PSS we need a provider plugin implementing a state store.
// Here will build the simple6 (built with protocol v6) provider, which implements PSS.
simple6Provider := filepath.Join(tf.WorkDir(), "terraform-provider-simple6")
simple6ProviderExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple-v6/main", simple6Provider)

// Move the provider binaries into a directory that we will point terraform
// to using the -plugin-dir cli flag.
platform := getproviders.CurrentPlatform.String()
hashiDir := "cache/registry.terraform.io/hashicorp/"
if err := os.MkdirAll(tf.Path(hashiDir, "simple6/0.0.1/", platform), os.ModePerm); err != nil {
t.Fatal(err)
}
if err := os.Rename(simple6ProviderExe, tf.Path(hashiDir, "simple6/0.0.1/", platform, "terraform-provider-simple6")); err != nil {
t.Fatal(err)
}

//// INIT
stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color")
if err != nil {
t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr)
}

if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") {
t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout)
}

//// PLAN
// No separate plan step; this test lets the apply make a plan.

//// APPLY
stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color")
if err != nil {
t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr)
}

if !strings.Contains(stdout, "Resources: 1 added, 0 changed, 0 destroyed") {
t.Errorf("incorrect apply tally; want 1 added:\n%s", stdout)
}

// Check the statefile saved by the fs state store.
path := "terraform.tfstate.d/default/terraform.tfstate"
f, err := tf.OpenFile(path)
if err != nil {
t.Fatalf("unexpected error opening state file %s: %s\nstderr:\n%s", path, err, stderr)
}
defer f.Close()

stateFile, err := statefile.Read(f)
if err != nil {
t.Fatalf("unexpected error reading statefile %s: %s\nstderr:\n%s", path, err, stderr)
}

r := stateFile.State.RootModule().Resources
if len(r) != 1 {
t.Fatalf("expected state to include one resource, but got %d", len(r))
}
if _, ok := r["terraform_data.my-data"]; !ok {
t.Fatalf("expected state to include terraform_data.my-data but it's missing")
}
}

// Requires TF_TEST_EXPERIMENTS to be set in the environment
func TestPrimary_stateStore_inMem(t *testing.T) {
if v := os.Getenv("TF_TEST_EXPERIMENTS"); v == "" {
t.Skip("can't run without enabling experiments in the executable terraform binary, enable with TF_TEST_EXPERIMENTS=1")
}

if !canRunGoBuild {
// We're running in a separate-build-then-run context, so we can't
// currently execute this test which depends on being able to build
// new executable at runtime.
//
// (See the comment on canRunGoBuild's declaration for more information.)
t.Skip("can't run without building a new provider executable")
}
t.Parallel()

tf := e2e.NewBinary(t, terraformBin, "testdata/full-workflow-with-state-store-inmem")

// In order to test integration with PSS we need a provider plugin implementing a state store.
// Here will build the simple6 (built with protocol v6) provider, which implements PSS.
simple6Provider := filepath.Join(tf.WorkDir(), "terraform-provider-simple6")
simple6ProviderExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple-v6/main", simple6Provider)

// Move the provider binaries into a directory that we will point terraform
// to using the -plugin-dir cli flag.
platform := getproviders.CurrentPlatform.String()
hashiDir := "cache/registry.terraform.io/hashicorp/"
if err := os.MkdirAll(tf.Path(hashiDir, "simple6/0.0.1/", platform), os.ModePerm); err != nil {
t.Fatal(err)
}
if err := os.Rename(simple6ProviderExe, tf.Path(hashiDir, "simple6/0.0.1/", platform, "terraform-provider-simple6")); err != nil {
t.Fatal(err)
}

//// INIT
//
// Note - the inmem PSS implementation means that the default workspace state created during init
// is lost as soon as the command completes.
stdout, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color")
if err != nil {
t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr)
}

if !strings.Contains(stdout, "Terraform created an empty state file for the default workspace") {
t.Errorf("notice about creating the default workspace is missing from init output:\n%s", stdout)
}

//// PLAN
// No separate plan step; this test lets the apply make a plan.

//// APPLY
//
// Note - the inmem PSS implementation means that writing to the default workspace during apply
// is creating the default state file for the first time.
stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color")
if err != nil {
t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr)
}

if !strings.Contains(stdout, "Resources: 1 added, 0 changed, 0 destroyed") {
t.Errorf("incorrect apply tally; want 1 added:\n%s", stdout)
}

// We cannot inspect state or perform a destroy here, as the state isn't persisted between steps
// when we use the simple6_inmem state store.
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
terraform {
required_providers {
simple6 = {
source = "registry.terraform.io/hashicorp/simple6"
}
}

state_store "simple6_fs" {
provider "simple6" {}
}
}

variable "name" {
default = "world"
}

resource "terraform_data" "my-data" {
input = "hello ${var.name}"
}

output "greeting" {
value = resource.terraform_data.my-data.output
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
terraform {
required_providers {
simple6 = {
source = "registry.terraform.io/hashicorp/simple6"
}
}

state_store "simple6_inmem" {
provider "simple6" {}
}
}

variable "name" {
default = "world"
}

resource "terraform_data" "my-data" {
input = "hello ${var.name}"
}

output "greeting" {
value = resource.terraform_data.my-data.output
}
26 changes: 6 additions & 20 deletions internal/command/meta_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,20 +50,6 @@ import (
tfversion "github.com/hashicorp/terraform/version"
)

const (
// defaultStateStoreChunkSize is the default chunk size proposed
// to the provider.
// This can be tweaked but should provide reasonable performance
// trade-offs for average network conditions and state file sizes.
defaultStateStoreChunkSize int64 = 8 << 20 // 8 MB

// maxStateStoreChunkSize is the highest chunk size provider may choose
// which we still consider reasonable/safe.
// This reflects terraform-plugin-go's max. RPC message size of 256MB
// and leaves plenty of space for other variable data like diagnostics.
maxStateStoreChunkSize int64 = 128 << 20 // 128 MB
)

// BackendOpts are the options used to initialize a backendrun.OperationsBackend.
type BackendOpts struct {
// BackendConfig is a representation of the backend configuration block given in
Expand Down Expand Up @@ -2085,7 +2071,7 @@ func (m *Meta) savedStateStore(sMgr *clistate.LocalState, factory providers.Fact
TypeName: s.StateStore.Type,
Config: stateStoreConfigVal,
Capabilities: providers.StateStoreClientCapabilities{
ChunkSize: defaultStateStoreChunkSize,
ChunkSize: backendPluggable.DefaultStateStoreChunkSize,
},
})
diags = diags.Append(cfgStoreResp.Diagnostics)
Expand All @@ -2094,10 +2080,10 @@ func (m *Meta) savedStateStore(sMgr *clistate.LocalState, factory providers.Fact
}

chunkSize := cfgStoreResp.Capabilities.ChunkSize
if chunkSize == 0 || chunkSize > maxStateStoreChunkSize {
if chunkSize == 0 || chunkSize > backendPluggable.MaxStateStoreChunkSize {
diags = diags.Append(fmt.Errorf("Failed to negotiate acceptable chunk size. "+
"Expected size > 0 and <= %d bytes, provider wants %d bytes",
maxStateStoreChunkSize, chunkSize,
backendPluggable.MaxStateStoreChunkSize, chunkSize,
))
return nil, diags
}
Expand Down Expand Up @@ -2362,7 +2348,7 @@ func (m *Meta) stateStoreInitFromConfig(c *configs.StateStore, factory providers
TypeName: c.Type,
Config: stateStoreConfigVal,
Capabilities: providers.StateStoreClientCapabilities{
ChunkSize: defaultStateStoreChunkSize,
ChunkSize: backendPluggable.DefaultStateStoreChunkSize,
},
})
diags = diags.Append(cfgStoreResp.Diagnostics)
Expand All @@ -2371,10 +2357,10 @@ func (m *Meta) stateStoreInitFromConfig(c *configs.StateStore, factory providers
}

chunkSize := cfgStoreResp.Capabilities.ChunkSize
if chunkSize == 0 || chunkSize > maxStateStoreChunkSize {
if chunkSize == 0 || chunkSize > backendPluggable.MaxStateStoreChunkSize {
diags = diags.Append(fmt.Errorf("Failed to negotiate acceptable chunk size. "+
"Expected size > 0 and <= %d bytes, provider wants %d bytes",
maxStateStoreChunkSize, chunkSize,
backendPluggable.MaxStateStoreChunkSize, chunkSize,
))
return nil, cty.NilVal, cty.NilVal, diags
}
Expand Down
Loading