Skip to content

Commit f591872

Browse files
PSS: Allow pluggable state store configuration to be stored in a plan file (#37956)
* refactor: Rename Meta's backendState field to backendConfigState This helps with navigating ambiguity around the word backend. The new name should indicate that the value represents a `backend` block, not a more general interpretation of what a backend is. * fix: Only set backendConfigState to synthetic object if it's nil due to a lack of data. Don't change it if pluggable state storage is in use. * feat: Enable recording a state store's details in an Operation, and using that data when creating a plan file. * fix: Include provider config when writing a plan file using pluggable state storage * fix: Having `backendConfigState` be nil may be valid, but it definitely isn't valid for `stateStoreConfigState` to be nil When backendConfigState is nil it means that an implied local backend is in use, i.e. there is no backend block in the config. * test: Add integration test showing that a plan command creates a plan file with the expected state_store configuration data * refactor: Apply suggestion from @radeksimko Co-authored-by: Radek Simko <[email protected]> * fix: Allow panics to occur if an unexpected implementation of `backend.Backend` is encountered when managing a state store * docs: Add code comment explaining the current situation with passing backend config state to downstream logic. In future this should be simplified, either via refactoring or changes affecting the implied local backend --------- Co-authored-by: Radek Simko <[email protected]>
1 parent 2eb22c8 commit f591872

File tree

14 files changed

+525
-74
lines changed

14 files changed

+525
-74
lines changed

internal/backend/backendrun/operation.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -73,14 +73,20 @@ type Operation struct {
7373

7474
// PlanId is an opaque value that backends can use to execute a specific
7575
// plan for an apply operation.
76-
//
76+
PlanId string
77+
PlanRefresh bool // PlanRefresh will do a refresh before a plan
78+
PlanOutPath string // PlanOutPath is the path to save the plan
79+
7780
// PlanOutBackend is the backend to store with the plan. This is the
7881
// backend that will be used when applying the plan.
79-
PlanId string
80-
PlanRefresh bool // PlanRefresh will do a refresh before a plan
81-
PlanOutPath string // PlanOutPath is the path to save the plan
82+
// Only one of PlanOutBackend or PlanOutStateStore may be set.
8283
PlanOutBackend *plans.Backend
8384

85+
// PlanOutStateStore is the state_store to store with the plan. This is the
86+
// state store that will be used when applying the plan.
87+
// Only one of PlanOutBackend or PlanOutStateStore may be set
88+
PlanOutStateStore *plans.StateStore
89+
8490
// ConfigDir is the path to the directory containing the configuration's
8591
// root module.
8692
ConfigDir string

internal/backend/local/backend_plan.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -149,16 +149,22 @@ func (b *Local) opPlan(
149149

150150
// Save the plan to disk
151151
if path := op.PlanOutPath; path != "" {
152-
if op.PlanOutBackend == nil {
152+
switch {
153+
case op.PlanOutStateStore != nil:
154+
plan.StateStore = op.PlanOutStateStore
155+
case op.PlanOutBackend != nil:
156+
plan.Backend = op.PlanOutBackend
157+
default:
153158
// This is always a bug in the operation caller; it's not valid
154-
// to set PlanOutPath without also setting PlanOutBackend.
159+
// to set PlanOutPath without also setting PlanOutStateStore or PlanOutBackend.
160+
// Even when there is no state_store or backend block in the configuration, there should be a PlanOutBackend
161+
// describing the implied local backend.
155162
diags = diags.Append(fmt.Errorf(
156-
"PlanOutPath set without also setting PlanOutBackend (this is a bug in Terraform)"),
163+
"PlanOutPath set without also setting PlanOutStateStore or PlanOutBackend (this is a bug in Terraform)"),
157164
)
158165
op.ReportResult(runningOp, diags)
159166
return
160167
}
161-
plan.Backend = op.PlanOutBackend
162168

163169
// We may have updated the state in the refresh step above, but we
164170
// will freeze that updated state in the plan file for now and

internal/backend/local/backend_plan_test.go

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212

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

15+
"github.com/hashicorp/go-version"
1516
"github.com/hashicorp/terraform/internal/addrs"
1617
"github.com/hashicorp/terraform/internal/backend/backendrun"
1718
"github.com/hashicorp/terraform/internal/command/arguments"
@@ -913,3 +914,155 @@ func TestLocal_invalidOptions(t *testing.T) {
913914
t.Fatal("expected error output")
914915
}
915916
}
917+
918+
// Checks if the state store info set on an Operation makes it into the resulting Plan
919+
func TestLocal_plan_withStateStore(t *testing.T) {
920+
b := TestLocal(t)
921+
922+
// Note: the mock provider doesn't include an implementation of
923+
// pluggable state storage, but that's not needed for this test.
924+
TestLocalProvider(t, b, "test", planFixtureSchema())
925+
mockAddr := addrs.NewDefaultProvider("test")
926+
providerVersion := version.Must(version.NewSemver("0.0.1"))
927+
storeType := "test_foobar"
928+
defaultWorkspace := "default"
929+
930+
testStateFile(t, b.StatePath, testPlanState_withDataSource())
931+
932+
outDir := t.TempDir()
933+
planPath := filepath.Join(outDir, "plan.tfplan")
934+
935+
// Note: the config doesn't include a state_store block. Instead,
936+
// that data is provided below when assigning a value to op.PlanOutStateStore.
937+
// Usually that data is set as a result of parsing configuration.
938+
op, configCleanup, _ := testOperationPlan(t, "./testdata/plan")
939+
defer configCleanup()
940+
op.PlanMode = plans.NormalMode
941+
op.PlanRefresh = true
942+
op.PlanOutPath = planPath
943+
storeCfg := cty.ObjectVal(map[string]cty.Value{
944+
"path": cty.StringVal(b.StatePath),
945+
})
946+
storeCfgRaw, err := plans.NewDynamicValue(storeCfg, storeCfg.Type())
947+
if err != nil {
948+
t.Fatal(err)
949+
}
950+
providerCfg := cty.ObjectVal(map[string]cty.Value{}) // Empty as the mock provider has no schema for the provider
951+
providerCfgRaw, err := plans.NewDynamicValue(providerCfg, providerCfg.Type())
952+
if err != nil {
953+
t.Fatal(err)
954+
}
955+
op.PlanOutStateStore = &plans.StateStore{
956+
Type: storeType,
957+
Config: storeCfgRaw,
958+
Provider: &plans.Provider{
959+
Source: &mockAddr,
960+
Version: providerVersion,
961+
Config: providerCfgRaw,
962+
},
963+
Workspace: defaultWorkspace,
964+
}
965+
966+
run, err := b.Operation(context.Background(), op)
967+
if err != nil {
968+
t.Fatalf("bad: %s", err)
969+
}
970+
<-run.Done()
971+
if run.Result != backendrun.OperationSuccess {
972+
t.Fatalf("plan operation failed")
973+
}
974+
975+
if run.PlanEmpty {
976+
t.Fatal("plan should not be empty")
977+
}
978+
979+
plan := testReadPlan(t, planPath)
980+
981+
// The plan should contain details about the state store
982+
if plan.StateStore == nil {
983+
t.Fatalf("Expected plan to describe a state store, but data was missing")
984+
}
985+
// The plan should NOT contain details about a backend
986+
if plan.Backend != nil {
987+
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)
988+
}
989+
990+
if plan.StateStore.Type != storeType {
991+
t.Errorf("Expected plan to describe a state store with type %s, but got %s", storeType, plan.StateStore.Type)
992+
}
993+
if plan.StateStore.Workspace != defaultWorkspace {
994+
t.Errorf("Expected plan to describe a state store with workspace %s, but got %s", defaultWorkspace, plan.StateStore.Workspace)
995+
}
996+
if !plan.StateStore.Provider.Source.Equals(mockAddr) {
997+
t.Errorf("Expected plan to describe a state store with provider address %s, but got %s", mockAddr, plan.StateStore.Provider.Source)
998+
}
999+
if !plan.StateStore.Provider.Version.Equal(providerVersion) {
1000+
t.Errorf("Expected plan to describe a state store with provider version %s, but got %s", providerVersion, plan.StateStore.Provider.Version)
1001+
}
1002+
}
1003+
1004+
// Checks if the backend info set on an Operation makes it into the resulting Plan
1005+
func TestLocal_plan_withBackend(t *testing.T) {
1006+
b := TestLocal(t)
1007+
1008+
TestLocalProvider(t, b, "test", planFixtureSchema())
1009+
1010+
testStateFile(t, b.StatePath, testPlanState_withDataSource())
1011+
1012+
outDir := t.TempDir()
1013+
planPath := filepath.Join(outDir, "plan.tfplan")
1014+
1015+
// Note: the config doesn't include a backend block. Instead,
1016+
// that data is provided below when assigning a value to op.PlanOutBackend.
1017+
// Usually that data is set as a result of parsing configuration.
1018+
op, configCleanup, _ := testOperationPlan(t, "./testdata/plan")
1019+
defer configCleanup()
1020+
op.PlanMode = plans.NormalMode
1021+
op.PlanRefresh = true
1022+
op.PlanOutPath = planPath
1023+
cfg := cty.ObjectVal(map[string]cty.Value{
1024+
"path": cty.StringVal(b.StatePath),
1025+
})
1026+
cfgRaw, err := plans.NewDynamicValue(cfg, cfg.Type())
1027+
if err != nil {
1028+
t.Fatal(err)
1029+
}
1030+
backendType := "foobar"
1031+
defaultWorkspace := "default"
1032+
op.PlanOutBackend = &plans.Backend{
1033+
Type: backendType,
1034+
Config: cfgRaw,
1035+
Workspace: defaultWorkspace,
1036+
}
1037+
1038+
run, err := b.Operation(context.Background(), op)
1039+
if err != nil {
1040+
t.Fatalf("bad: %s", err)
1041+
}
1042+
<-run.Done()
1043+
if run.Result != backendrun.OperationSuccess {
1044+
t.Fatalf("plan operation failed")
1045+
}
1046+
1047+
if run.PlanEmpty {
1048+
t.Fatal("plan should not be empty")
1049+
}
1050+
1051+
plan := testReadPlan(t, planPath)
1052+
1053+
// The plan should contain details about the backend
1054+
if plan.Backend == nil {
1055+
t.Fatalf("Expected plan to describe a backend, but data was missing")
1056+
}
1057+
// The plan should NOT contain details about a state store
1058+
if plan.StateStore != nil {
1059+
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)
1060+
}
1061+
1062+
if plan.Backend.Type != backendType {
1063+
t.Errorf("Expected plan to describe a backend with type %s, but got %s", backendType, plan.Backend.Type)
1064+
}
1065+
if plan.Backend.Workspace != defaultWorkspace {
1066+
t.Errorf("Expected plan to describe a backend with workspace %s, but got %s", defaultWorkspace, plan.Backend.Workspace)
1067+
}
1068+
}

internal/command/meta.go

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,8 +208,12 @@ type Meta struct {
208208
// It is initialized on first use.
209209
configLoader *configload.Loader
210210

211-
// backendState is the currently active backend state
212-
backendState *workdir.BackendConfigState
211+
// backendConfigState is the currently active backend state.
212+
// This is used when creating plan files.
213+
backendConfigState *workdir.BackendConfigState
214+
// stateStoreConfigState is the currently active state_store state.
215+
// This is used when creating plan files.
216+
stateStoreConfigState *workdir.StateStoreConfigState
213217

214218
// Variables for the context (private)
215219
variableArgs arguments.FlagNameValueSlice

internal/command/meta_backend.go

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import (
2929
"github.com/hashicorp/terraform/internal/backend"
3030
"github.com/hashicorp/terraform/internal/backend/backendrun"
3131
backendInit "github.com/hashicorp/terraform/internal/backend/init"
32+
"github.com/hashicorp/terraform/internal/backend/local"
3233
backendLocal "github.com/hashicorp/terraform/internal/backend/local"
3334
backendPluggable "github.com/hashicorp/terraform/internal/backend/pluggable"
3435
"github.com/hashicorp/terraform/internal/cloud"
@@ -37,6 +38,7 @@ import (
3738
"github.com/hashicorp/terraform/internal/command/views"
3839
"github.com/hashicorp/terraform/internal/command/workdir"
3940
"github.com/hashicorp/terraform/internal/configs"
41+
"github.com/hashicorp/terraform/internal/configs/configschema"
4042
"github.com/hashicorp/terraform/internal/depsfile"
4143
"github.com/hashicorp/terraform/internal/didyoumean"
4244
"github.com/hashicorp/terraform/internal/getproviders/providerreqs"
@@ -221,13 +223,14 @@ func (m *Meta) Backend(opts *BackendOpts) (backendrun.OperationsBackend, tfdiags
221223
// the user, since the local backend should only be used when learning or
222224
// in exceptional cases and so it's better to help the user learn that
223225
// by introducing it as a concept.
224-
if m.backendState == nil {
226+
stateStoreInUse := opts.StateStoreConfig != nil
227+
if !stateStoreInUse && m.backendConfigState == nil {
225228
// NOTE: This synthetic object is intentionally _not_ retained in the
226229
// on-disk record of the backend configuration, which was already dealt
227230
// with inside backendFromConfig, because we still need that codepath
228231
// to be able to recognize the lack of a config as distinct from
229232
// explicitly setting local until we do some more refactoring here.
230-
m.backendState = &workdir.BackendConfigState{
233+
m.backendConfigState = &workdir.BackendConfigState{
231234
Type: "local",
232235
ConfigRaw: json.RawMessage("{}"),
233236
}
@@ -440,13 +443,38 @@ func (m *Meta) Operation(b backend.Backend, vt arguments.ViewType) *backendrun.O
440443
// here first is a bug, so panic.
441444
panic(fmt.Sprintf("invalid workspace: %s", err))
442445
}
443-
planOutBackend, err := m.backendState.PlanData(schema, nil, workspace)
444-
if err != nil {
445-
// Always indicates an implementation error in practice, because
446-
// errors here indicate invalid encoding of the backend configuration
447-
// in memory, and we should always have validated that by the time
448-
// we get here.
449-
panic(fmt.Sprintf("failed to encode backend configuration for plan: %s", err))
446+
447+
var planOutBackend *plans.Backend
448+
var planOutStateStore *plans.StateStore
449+
switch {
450+
case m.backendConfigState != nil && m.stateStoreConfigState != nil:
451+
// Both set
452+
panic("failed to encode backend configuration for plan: both backend and state_store data present but they are mutually exclusive")
453+
case m.stateStoreConfigState != nil:
454+
// To access the provider schema, we need to access the underlying backends
455+
var providerSchema *configschema.Block
456+
lb := b.(*local.Local)
457+
p := lb.Backend.(*backendPluggable.Pluggable)
458+
providerSchema = p.ProviderSchema()
459+
460+
planOutStateStore, err = m.stateStoreConfigState.PlanData(schema, providerSchema, workspace)
461+
if err != nil {
462+
// Always indicates an implementation error in practice, because
463+
// errors here indicate invalid encoding of the state_store configuration
464+
// in memory, and we should always have validated that by the time
465+
// we get here.
466+
panic(fmt.Sprintf("failed to encode state_store configuration for plan: %s", err))
467+
}
468+
default:
469+
// Either backendConfigState is set, or it's nil; PlanData method can handle either.
470+
planOutBackend, err = m.backendConfigState.PlanData(schema, nil, workspace)
471+
if err != nil {
472+
// Always indicates an implementation error in practice, because
473+
// errors here indicate invalid encoding of the backend configuration
474+
// in memory, and we should always have validated that by the time
475+
// we get here.
476+
panic(fmt.Sprintf("failed to encode backend configuration for plan: %s", err))
477+
}
450478
}
451479

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

468-
return &backendrun.Operation{
469-
PlanOutBackend: planOutBackend,
496+
op := &backendrun.Operation{
497+
// These two fields are mutually exclusive; one is being assigned a nil value below.
498+
PlanOutBackend: planOutBackend,
499+
PlanOutStateStore: planOutStateStore,
500+
470501
Targets: m.targets,
471502
UIIn: m.UIInput(),
472503
UIOut: m.Ui,
473504
Workspace: workspace,
474505
StateLocker: stateLocker,
475506
DependencyLocks: depLocks,
476507
}
508+
509+
if op.PlanOutBackend != nil && op.PlanOutStateStore != nil {
510+
panic("failed to prepare operation: both backend and state_store configurations are present")
511+
}
512+
513+
return op
477514
}
478515

479516
// backendConfig returns the local configuration for the backend
@@ -727,10 +764,28 @@ func (m *Meta) backendFromConfig(opts *BackendOpts) (backend.Backend, tfdiags.Di
727764

728765
// Upon return, we want to set the state we're using in-memory so that
729766
// we can access it for commands.
730-
m.backendState = nil
767+
//
768+
// Currently the only command using these values is the `plan` command,
769+
// which records the data in the plan file.
770+
m.backendConfigState = nil
771+
m.stateStoreConfigState = nil
731772
defer func() {
732-
if s := sMgr.State(); s != nil && !s.Backend.Empty() {
733-
m.backendState = s.Backend
773+
s := sMgr.State()
774+
switch {
775+
case s == nil:
776+
// Do nothing
777+
/* If there is no backend state file then either:
778+
1. The working directory isn't initialized yet.
779+
The user is either in the process of running an init command, in which case the values set via this deferred function will not be used,
780+
or they are performing a non-init command that will be interrupted by an error before these values are used in downstream
781+
2. There isn't any backend or state_store configuration and an implied local backend is in use.
782+
This is valid and will mean m.backendConfigState is nil until the calling code adds a synthetic object in:
783+
https://github.com/hashicorp/terraform/blob/3eea12a1d810a17e9c8e43cf7774817641ca9bc1/internal/command/meta_backend.go#L213-L234
784+
*/
785+
case !s.Backend.Empty():
786+
m.backendConfigState = s.Backend
787+
case !s.StateStore.Empty():
788+
m.stateStoreConfigState = s.StateStore
734789
}
735790
}()
736791

@@ -1781,11 +1836,12 @@ func (m *Meta) stateStore_C_s(c *configs.StateStore, stateStoreHash int, backend
17811836
},
17821837
}
17831838
s.StateStore.SetConfig(storeConfigVal, b.ConfigSchema())
1784-
if plug, ok := b.(*backendPluggable.Pluggable); ok {
1785-
// We need to convert away from backend.Backend interface to use the method
1786-
// for accessing the provider schema.
1787-
s.StateStore.Provider.SetConfig(providerConfigVal, plug.ProviderSchema())
1788-
}
1839+
1840+
// We need to briefly convert away from backend.Backend interface to use the method
1841+
// for accessing the provider schema. In this method we _always_ expect the concrete value
1842+
// to be backendPluggable.Pluggable.
1843+
plug := b.(*backendPluggable.Pluggable)
1844+
s.StateStore.Provider.SetConfig(providerConfigVal, plug.ProviderSchema())
17891845

17901846
// Verify that selected workspace exists in the state store.
17911847
if opts.Init && b != nil {

0 commit comments

Comments
 (0)