Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
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
Prev Previous commit
Next Next commit
feat: Enable recording a state store's details in an Operation, and u…
…sing that data when creating a plan file.
  • Loading branch information
SarahFrench committed Dec 2, 2025
commit d0f8834c460e7cded2ff921eb11e542c14b7d875
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
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
148 changes: 148 additions & 0 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 @@ -913,3 +914,150 @@ 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
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)
}
op.PlanOutStateStore = &plans.StateStore{
Type: storeType,
Config: cfgRaw,
Provider: &plans.Provider{
Source: &mockAddr,
Version: providerVersion,
// No config as the mock provider has no schema for the provider
},
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)
}
}
6 changes: 5 additions & 1 deletion internal/command/meta.go
Original file line number Diff line number Diff line change
Expand Up @@ -208,8 +208,12 @@ type Meta struct {
// It is initialized on first use.
configLoader *configload.Loader

// backendConfigState is the currently active backend state
// backendConfigState is the currently active backend state.
// This is used when creating plan files.
backendConfigState *workdir.BackendConfigState
// stateStoreConfigState is the currently active state_store state.
// This is used when creating plan files.
stateStoreConfigState *workdir.StateStoreConfigState

// Variables for the context (private)
variableArgs arguments.FlagNameValueSlice
Expand Down
73 changes: 63 additions & 10 deletions internal/command/meta_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/hashicorp/terraform/internal/backend"
"github.com/hashicorp/terraform/internal/backend/backendrun"
backendInit "github.com/hashicorp/terraform/internal/backend/init"
"github.com/hashicorp/terraform/internal/backend/local"
backendLocal "github.com/hashicorp/terraform/internal/backend/local"
backendPluggable "github.com/hashicorp/terraform/internal/backend/pluggable"
"github.com/hashicorp/terraform/internal/cloud"
Expand All @@ -37,6 +38,7 @@ import (
"github.com/hashicorp/terraform/internal/command/views"
"github.com/hashicorp/terraform/internal/command/workdir"
"github.com/hashicorp/terraform/internal/configs"
"github.com/hashicorp/terraform/internal/configs/configschema"
"github.com/hashicorp/terraform/internal/depsfile"
"github.com/hashicorp/terraform/internal/didyoumean"
"github.com/hashicorp/terraform/internal/getproviders/providerreqs"
Expand Down Expand Up @@ -441,13 +443,44 @@ func (m *Meta) Operation(b backend.Backend, vt arguments.ViewType) *backendrun.O
// here first is a bug, so panic.
panic(fmt.Sprintf("invalid workspace: %s", err))
}
planOutBackend, err := m.backendState.PlanData(schema, nil, workspace)
if err != nil {
// Always indicates an implementation error in practice, because
// errors here indicate invalid encoding of the backend configuration
// in memory, and we should always have validated that by the time
// we get here.
panic(fmt.Sprintf("failed to encode backend configuration for plan: %s", err))

var planOutBackend *plans.Backend
var planOutStateStore *plans.StateStore
switch {
case m.backendConfigState == nil && m.stateStoreConfigState == nil:
// Neither set
panic("failed to encode backend configuration for plan: neither backend nor state_store data present")
case m.backendConfigState != nil && m.stateStoreConfigState != nil:
// Both set
panic("failed to encode backend configuration for plan: both backend and state_store data present but they are mutually exclusive")
case m.backendConfigState != nil:
planOutBackend, err = m.backendConfigState.PlanData(schema, nil, workspace)
if err != nil {
// Always indicates an implementation error in practice, because
// errors here indicate invalid encoding of the backend configuration
// in memory, and we should always have validated that by the time
// we get here.
panic(fmt.Sprintf("failed to encode backend configuration for plan: %s", err))
}
case m.stateStoreConfigState != nil:
// To access the provider schema, we need to access the underlying backends
var providerSchema *configschema.Block
if lb, ok := b.(*local.Local); ok {
if p, ok := lb.Backend.(*backendPluggable.Pluggable); ok {
providerSchema = p.ProviderSchema()
}
}

// TODO: do we need to protect against a nil provider schema? When a provider has an empty schema does that present as nil?

planOutStateStore, err = m.stateStoreConfigState.PlanData(schema, providerSchema, workspace)
if err != nil {
// Always indicates an implementation error in practice, because
// errors here indicate invalid encoding of the state_store configuration
// in memory, and we should always have validated that by the time
// we get here.
panic(fmt.Sprintf("failed to encode state_store configuration for plan: %s", err))
}
}

stateLocker := clistate.NewNoopLocker()
Expand All @@ -466,15 +499,24 @@ func (m *Meta) Operation(b backend.Backend, vt arguments.ViewType) *backendrun.O
log.Printf("[WARN] Failed to load dependency locks while preparing backend operation (ignored): %s", diags.Err().Error())
}

return &backendrun.Operation{
PlanOutBackend: planOutBackend,
op := &backendrun.Operation{
// These two fields are mutually exclusive; one is being assigned a nil value below.
PlanOutBackend: planOutBackend,
PlanOutStateStore: planOutStateStore,

Targets: m.targets,
UIIn: m.UIInput(),
UIOut: m.Ui,
Workspace: workspace,
StateLocker: stateLocker,
DependencyLocks: depLocks,
}

if op.PlanOutBackend != nil && op.PlanOutStateStore != nil {
panic("failed to prepare operation: both backend and state_store configurations are present")
}

return op
}

// backendConfig returns the local configuration for the backend
Expand Down Expand Up @@ -729,9 +771,20 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di
// Upon return, we want to set the state we're using in-memory so that
// we can access it for commands.
m.backendConfigState = nil
m.stateStoreConfigState = nil
defer func() {
if s := sMgr.State(); s != nil && !s.Backend.Empty() {
s := sMgr.State()
switch {
case s == nil:
// Do nothing

// TODO: Should we add a synthetic object here,
// as part of addressing actions described in this FIXME?
// https://github.com/hashicorp/terraform/blob/053738fbf08d50261eccb463580525b88f461d8e/internal/command/meta_backend.go#L222-L243
case !s.Backend.Empty():
m.backendConfigState = s.Backend
case !s.StateStore.Empty():
m.stateStoreConfigState = s.StateStore
}
}()

Expand Down