Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
f03346b
refactor: Rename Meta's backendState field to backendConfigState
SarahFrench Nov 13, 2025
fb35d84
fix: Only set backendConfigState to synthetic object if it's nil due …
SarahFrench Nov 13, 2025
d0f8834
feat: Enable recording a state store's details in an Operation, and u…
SarahFrench Nov 14, 2025
aaffae2
fix: Include provider config when writing a plan file using pluggable…
SarahFrench Nov 25, 2025
59a8f83
fix: Having `backendConfigState` be nil may be valid, but it definite…
SarahFrench Nov 26, 2025
9243396
test: Add integration test showing that a plan command creates a plan…
SarahFrench Nov 26, 2025
44b0a6e
feat: Allow reading state store configuration from a planfile and usi…
SarahFrench Nov 18, 2025
708e3b2
test: Assert that we can get and use state store configuration from a…
SarahFrench Nov 18, 2025
360bcf7
test: Add integration test showing that an apply command can use a pl…
SarahFrench Nov 26, 2025
225f16f
test: Add E2E test showing pluggable state storage being used with th…
SarahFrench Nov 26, 2025
945c10e
feat: A plan file will report the state storage provider among its re…
SarahFrench Nov 26, 2025
6a44102
fix: Include error messages when there is a problem parsing provider …
SarahFrench Nov 27, 2025
a61fe8e
test: Update test helper to set Workspace in planfile description of …
SarahFrench Nov 26, 2025
e990a27
test: Update tests so that all fields in the plan's representation of…
SarahFrench Nov 27, 2025
549b21e
feat: Add `Validate` method for asserting that a plan's description o…
SarahFrench Nov 14, 2025
e3f20b8
feat: Make Terraform assert that the description of a backend or stat…
SarahFrench Nov 27, 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
14 changes: 10 additions & 4 deletions internal/backend/backendrun/operation.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,14 +73,20 @@ type Operation struct {

// PlanId is an opaque value that backends can use to execute a specific
// plan for an apply operation.
//
PlanId string
PlanRefresh bool // PlanRefresh will do a refresh before a plan
PlanOutPath string // PlanOutPath is the path to save the plan

// PlanOutBackend is the backend to store with the plan. This is the
// backend that will be used when applying the plan.
PlanId string
PlanRefresh bool // PlanRefresh will do a refresh before a plan
PlanOutPath string // PlanOutPath is the path to save the plan
// Only one of PlanOutBackend or PlanOutStateStore may be set.
PlanOutBackend *plans.Backend

// PlanOutStateStore is the state_store to store with the plan. This is the
// state store that will be used when applying the plan.
// Only one of PlanOutBackend or PlanOutStateStore may be set
PlanOutStateStore *plans.StateStore

// ConfigDir is the path to the directory containing the configuration's
// root module.
ConfigDir string
Expand Down
5 changes: 3 additions & 2 deletions internal/backend/local/backend_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -161,8 +161,9 @@ func TestLocalRun_stalePlan(t *testing.T) {
UIMode: plans.NormalMode,
Changes: plans.NewChangesSrc(),
Backend: &plans.Backend{
Type: "local",
Config: backendConfigRaw,
Type: "local",
Config: backendConfigRaw,
Workspace: "default",
},
PrevRunState: states.NewState(),
PriorState: states.NewState(),
Expand Down
14 changes: 10 additions & 4 deletions internal/backend/local/backend_plan.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,22 @@ func (b *Local) opPlan(

// Save the plan to disk
if path := op.PlanOutPath; path != "" {
if op.PlanOutBackend == nil {
switch {
case op.PlanOutStateStore != nil:
plan.StateStore = op.PlanOutStateStore
case op.PlanOutBackend != nil:
plan.Backend = op.PlanOutBackend
default:
// This is always a bug in the operation caller; it's not valid
// to set PlanOutPath without also setting PlanOutBackend.
// to set PlanOutPath without also setting PlanOutStateStore or PlanOutBackend.
// Even when there is no state_store or backend block in the configuration, there should be a PlanOutBackend
// describing the implied local backend.
diags = diags.Append(fmt.Errorf(
"PlanOutPath set without also setting PlanOutBackend (this is a bug in Terraform)"),
"PlanOutPath set without also setting PlanOutStateStore or PlanOutBackend (this is a bug in Terraform)"),
)
op.ReportResult(runningOp, diags)
return
}
plan.Backend = op.PlanOutBackend

// We may have updated the state in the refresh step above, but we
// will freeze that updated state in the plan file for now and
Expand Down
193 changes: 177 additions & 16 deletions internal/backend/local/backend_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import (

"github.com/zclconf/go-cty/cty"

"github.com/hashicorp/go-version"
"github.com/hashicorp/terraform/internal/addrs"
"github.com/hashicorp/terraform/internal/backend/backendrun"
"github.com/hashicorp/terraform/internal/command/arguments"
Expand Down Expand Up @@ -206,8 +207,9 @@ func TestLocal_planOutputsChanged(t *testing.T) {
}
op.PlanOutBackend = &plans.Backend{
// Just a placeholder so that we can generate a valid plan file.
Type: "local",
Config: cfgRaw,
Type: "local",
Config: cfgRaw,
Workspace: "default",
}
run, err := b.Operation(context.Background(), op)
if err != nil {
Expand Down Expand Up @@ -262,8 +264,9 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) {
t.Fatal(err)
}
op.PlanOutBackend = &plans.Backend{
Type: "local",
Config: cfgRaw,
Type: "local",
Config: cfgRaw,
Workspace: "default",
}
run, err := b.Operation(context.Background(), op)
if err != nil {
Expand Down Expand Up @@ -304,8 +307,9 @@ func TestLocal_planTainted(t *testing.T) {
}
op.PlanOutBackend = &plans.Backend{
// Just a placeholder so that we can generate a valid plan file.
Type: "local",
Config: cfgRaw,
Type: "local",
Config: cfgRaw,
Workspace: "default",
}
run, err := b.Operation(context.Background(), op)
if err != nil {
Expand Down Expand Up @@ -383,8 +387,9 @@ func TestLocal_planDeposedOnly(t *testing.T) {
}
op.PlanOutBackend = &plans.Backend{
// Just a placeholder so that we can generate a valid plan file.
Type: "local",
Config: cfgRaw,
Type: "local",
Config: cfgRaw,
Workspace: "default",
}
run, err := b.Operation(context.Background(), op)
if err != nil {
Expand Down Expand Up @@ -474,8 +479,9 @@ func TestLocal_planTainted_createBeforeDestroy(t *testing.T) {
}
op.PlanOutBackend = &plans.Backend{
// Just a placeholder so that we can generate a valid plan file.
Type: "local",
Config: cfgRaw,
Type: "local",
Config: cfgRaw,
Workspace: "default",
}
run, err := b.Operation(context.Background(), op)
if err != nil {
Expand Down Expand Up @@ -565,8 +571,9 @@ func TestLocal_planDestroy(t *testing.T) {
}
op.PlanOutBackend = &plans.Backend{
// Just a placeholder so that we can generate a valid plan file.
Type: "local",
Config: cfgRaw,
Type: "local",
Config: cfgRaw,
Workspace: "default",
}

run, err := b.Operation(context.Background(), op)
Expand Down Expand Up @@ -617,8 +624,9 @@ func TestLocal_planDestroy_withDataSources(t *testing.T) {
}
op.PlanOutBackend = &plans.Backend{
// Just a placeholder so that we can generate a valid plan file.
Type: "local",
Config: cfgRaw,
Type: "local",
Config: cfgRaw,
Workspace: "default",
}

run, err := b.Operation(context.Background(), op)
Expand Down Expand Up @@ -689,8 +697,9 @@ func TestLocal_planOutPathNoChange(t *testing.T) {
}
op.PlanOutBackend = &plans.Backend{
// Just a placeholder so that we can generate a valid plan file.
Type: "local",
Config: cfgRaw,
Type: "local",
Config: cfgRaw,
Workspace: "default",
}
op.PlanRefresh = true

Expand Down Expand Up @@ -913,3 +922,155 @@ func TestLocal_invalidOptions(t *testing.T) {
t.Fatal("expected error output")
}
}

// Checks if the state store info set on an Operation makes it into the resulting Plan
func TestLocal_plan_withStateStore(t *testing.T) {
b := TestLocal(t)

// Note: the mock provider doesn't include an implementation of
// pluggable state storage, but that's not needed for this test.
TestLocalProvider(t, b, "test", planFixtureSchema())
mockAddr := addrs.NewDefaultProvider("test")
providerVersion := version.Must(version.NewSemver("0.0.1"))
storeType := "test_foobar"
defaultWorkspace := "default"

testStateFile(t, b.StatePath, testPlanState_withDataSource())

outDir := t.TempDir()
planPath := filepath.Join(outDir, "plan.tfplan")

// Note: the config doesn't include a state_store block. Instead,
// that data is provided below when assigning a value to op.PlanOutStateStore.
// Usually that data is set as a result of parsing configuration.
op, configCleanup, _ := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
op.PlanMode = plans.NormalMode
op.PlanRefresh = true
op.PlanOutPath = planPath
storeCfg := cty.ObjectVal(map[string]cty.Value{
"path": cty.StringVal(b.StatePath),
})
storeCfgRaw, err := plans.NewDynamicValue(storeCfg, storeCfg.Type())
if err != nil {
t.Fatal(err)
}
providerCfg := cty.ObjectVal(map[string]cty.Value{}) // Empty as the mock provider has no schema for the provider
providerCfgRaw, err := plans.NewDynamicValue(providerCfg, providerCfg.Type())
if err != nil {
t.Fatal(err)
}
op.PlanOutStateStore = &plans.StateStore{
Type: storeType,
Config: storeCfgRaw,
Provider: &plans.Provider{
Source: &mockAddr,
Version: providerVersion,
Config: providerCfgRaw,
},
Workspace: defaultWorkspace,
}

run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("bad: %s", err)
}
<-run.Done()
if run.Result != backendrun.OperationSuccess {
t.Fatalf("plan operation failed")
}

if run.PlanEmpty {
t.Fatal("plan should not be empty")
}

plan := testReadPlan(t, planPath)

// The plan should contain details about the state store
if plan.StateStore == nil {
t.Fatalf("Expected plan to describe a state store, but data was missing")
}
// The plan should NOT contain details about a backend
if plan.Backend != nil {
t.Errorf("Expected plan to not describe a backend because a state store is in use, but data was present:\n plan.Backend = %v", plan.Backend)
}

if plan.StateStore.Type != storeType {
t.Errorf("Expected plan to describe a state store with type %s, but got %s", storeType, plan.StateStore.Type)
}
if plan.StateStore.Workspace != defaultWorkspace {
t.Errorf("Expected plan to describe a state store with workspace %s, but got %s", defaultWorkspace, plan.StateStore.Workspace)
}
if !plan.StateStore.Provider.Source.Equals(mockAddr) {
t.Errorf("Expected plan to describe a state store with provider address %s, but got %s", mockAddr, plan.StateStore.Provider.Source)
}
if !plan.StateStore.Provider.Version.Equal(providerVersion) {
t.Errorf("Expected plan to describe a state store with provider version %s, but got %s", providerVersion, plan.StateStore.Provider.Version)
}
}

// Checks if the backend info set on an Operation makes it into the resulting Plan
func TestLocal_plan_withBackend(t *testing.T) {
b := TestLocal(t)

TestLocalProvider(t, b, "test", planFixtureSchema())

testStateFile(t, b.StatePath, testPlanState_withDataSource())

outDir := t.TempDir()
planPath := filepath.Join(outDir, "plan.tfplan")

// Note: the config doesn't include a backend block. Instead,
// that data is provided below when assigning a value to op.PlanOutBackend.
// Usually that data is set as a result of parsing configuration.
op, configCleanup, _ := testOperationPlan(t, "./testdata/plan")
defer configCleanup()
op.PlanMode = plans.NormalMode
op.PlanRefresh = true
op.PlanOutPath = planPath
cfg := cty.ObjectVal(map[string]cty.Value{
"path": cty.StringVal(b.StatePath),
})
cfgRaw, err := plans.NewDynamicValue(cfg, cfg.Type())
if err != nil {
t.Fatal(err)
}
backendType := "foobar"
defaultWorkspace := "default"
op.PlanOutBackend = &plans.Backend{
Type: backendType,
Config: cfgRaw,
Workspace: defaultWorkspace,
}

run, err := b.Operation(context.Background(), op)
if err != nil {
t.Fatalf("bad: %s", err)
}
<-run.Done()
if run.Result != backendrun.OperationSuccess {
t.Fatalf("plan operation failed")
}

if run.PlanEmpty {
t.Fatal("plan should not be empty")
}

plan := testReadPlan(t, planPath)

// The plan should contain details about the backend
if plan.Backend == nil {
t.Fatalf("Expected plan to describe a backend, but data was missing")
}
// The plan should NOT contain details about a state store
if plan.StateStore != nil {
t.Errorf("Expected plan to not describe a state store because a backend is in use, but data was present:\n plan.StateStore = %v", plan.StateStore)
}

if plan.Backend.Type != backendType {
t.Errorf("Expected plan to describe a backend with type %s, but got %s", backendType, plan.Backend.Type)
}
if plan.Backend.Workspace != defaultWorkspace {
t.Errorf("Expected plan to describe a backend with workspace %s, but got %s", defaultWorkspace, plan.Backend.Workspace)
}
}
8 changes: 4 additions & 4 deletions internal/command/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,17 +210,17 @@ func (c *ApplyCommand) PrepareBackend(planFile *planfile.WrappedPlanFile, args *
))
return nil, diags
}
if plan.Backend == nil {

if plan.Backend == nil && plan.StateStore == nil {
// Should never happen; always indicates a bug in the creation of the plan file
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to read plan from plan file",
"The given plan file does not have a valid backend configuration. This is a bug in the Terraform command that generated this plan file.",
"The given plan file does not have either a valid backend or state store configuration. This is a bug in the Terraform command that generated this plan file.",
))
return nil, diags
}
// TODO: Update BackendForLocalPlan to use state storage, and plan to be able to contain State Store config details
be, beDiags = c.BackendForLocalPlan(*plan.Backend)
be, beDiags = c.BackendForLocalPlan(plan)
} else {

// Load the backend
Expand Down
Loading