Skip to content

Commit 02a4ddc

Browse files
fix: Fail an apply command if the plan file was generated for a workspace that isn't the selected workspace (#37955)
* fix: Fail apply command if the plan file was generated for a workspace that isn't the selected workspace. * Add change file * test: Update test helper to include Workspace name in plan representation * fix: Make error message more generic, so is applicable to backend and cloud blocks. * fix: Make error message specific to backend or cloud block * test: Add separate tests for backend/cloud usage * test: Update remaining tests to include a value for Workspace in mocked plans * Apply suggestions from code review Co-authored-by: Radek Simko <radeksimko@users.noreply.github.com> * fix: Panic when a plan file has missing workspace data * test: Update test to match changes in error text --------- Co-authored-by: Radek Simko <radeksimko@users.noreply.github.com>
1 parent 379fa79 commit 02a4ddc

File tree

9 files changed

+210
-28
lines changed

9 files changed

+210
-28
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
kind: BUG FIXES
2+
body: 'apply: Terraform will raise an explicit error if a plan file intended for one workspace is applied against another workspace'
3+
time: 2025-12-01T11:49:50.360928Z
4+
custom:
5+
Issue: "37954"

internal/backend/local/backend_local_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -161,8 +161,9 @@ func TestLocalRun_stalePlan(t *testing.T) {
161161
UIMode: plans.NormalMode,
162162
Changes: plans.NewChangesSrc(),
163163
Backend: &plans.Backend{
164-
Type: "local",
165-
Config: backendConfigRaw,
164+
Type: "local",
165+
Config: backendConfigRaw,
166+
Workspace: "default",
166167
},
167168
PrevRunState: states.NewState(),
168169
PriorState: states.NewState(),

internal/backend/local/backend_plan_test.go

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -207,8 +207,9 @@ func TestLocal_planOutputsChanged(t *testing.T) {
207207
}
208208
op.PlanOutBackend = &plans.Backend{
209209
// Just a placeholder so that we can generate a valid plan file.
210-
Type: "local",
211-
Config: cfgRaw,
210+
Type: "local",
211+
Config: cfgRaw,
212+
Workspace: "default",
212213
}
213214
run, err := b.Operation(context.Background(), op)
214215
if err != nil {
@@ -263,8 +264,9 @@ func TestLocal_planModuleOutputsChanged(t *testing.T) {
263264
t.Fatal(err)
264265
}
265266
op.PlanOutBackend = &plans.Backend{
266-
Type: "local",
267-
Config: cfgRaw,
267+
Type: "local",
268+
Config: cfgRaw,
269+
Workspace: "default",
268270
}
269271
run, err := b.Operation(context.Background(), op)
270272
if err != nil {
@@ -305,8 +307,9 @@ func TestLocal_planTainted(t *testing.T) {
305307
}
306308
op.PlanOutBackend = &plans.Backend{
307309
// Just a placeholder so that we can generate a valid plan file.
308-
Type: "local",
309-
Config: cfgRaw,
310+
Type: "local",
311+
Config: cfgRaw,
312+
Workspace: "default",
310313
}
311314
run, err := b.Operation(context.Background(), op)
312315
if err != nil {
@@ -384,8 +387,9 @@ func TestLocal_planDeposedOnly(t *testing.T) {
384387
}
385388
op.PlanOutBackend = &plans.Backend{
386389
// Just a placeholder so that we can generate a valid plan file.
387-
Type: "local",
388-
Config: cfgRaw,
390+
Type: "local",
391+
Config: cfgRaw,
392+
Workspace: "default",
389393
}
390394
run, err := b.Operation(context.Background(), op)
391395
if err != nil {
@@ -475,8 +479,9 @@ func TestLocal_planTainted_createBeforeDestroy(t *testing.T) {
475479
}
476480
op.PlanOutBackend = &plans.Backend{
477481
// Just a placeholder so that we can generate a valid plan file.
478-
Type: "local",
479-
Config: cfgRaw,
482+
Type: "local",
483+
Config: cfgRaw,
484+
Workspace: "default",
480485
}
481486
run, err := b.Operation(context.Background(), op)
482487
if err != nil {
@@ -566,8 +571,9 @@ func TestLocal_planDestroy(t *testing.T) {
566571
}
567572
op.PlanOutBackend = &plans.Backend{
568573
// Just a placeholder so that we can generate a valid plan file.
569-
Type: "local",
570-
Config: cfgRaw,
574+
Type: "local",
575+
Config: cfgRaw,
576+
Workspace: "default",
571577
}
572578

573579
run, err := b.Operation(context.Background(), op)
@@ -618,8 +624,9 @@ func TestLocal_planDestroy_withDataSources(t *testing.T) {
618624
}
619625
op.PlanOutBackend = &plans.Backend{
620626
// Just a placeholder so that we can generate a valid plan file.
621-
Type: "local",
622-
Config: cfgRaw,
627+
Type: "local",
628+
Config: cfgRaw,
629+
Workspace: "default",
623630
}
624631

625632
run, err := b.Operation(context.Background(), op)
@@ -690,8 +697,9 @@ func TestLocal_planOutPathNoChange(t *testing.T) {
690697
}
691698
op.PlanOutBackend = &plans.Backend{
692699
// Just a placeholder so that we can generate a valid plan file.
693-
Type: "local",
694-
Config: cfgRaw,
700+
Type: "local",
701+
Config: cfgRaw,
702+
Workspace: "default",
695703
}
696704
op.PlanRefresh = true
697705

internal/command/apply_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1087,8 +1087,9 @@ func TestApply_plan_remoteState(t *testing.T) {
10871087
}
10881088
planPath := testPlanFile(t, snap, state, &plans.Plan{
10891089
Backend: &plans.Backend{
1090-
Type: "http",
1091-
Config: backendConfigRaw,
1090+
Type: "http",
1091+
Config: backendConfigRaw,
1092+
Workspace: "default",
10921093
},
10931094
Changes: plans.NewChangesSrc(),
10941095
})

internal/command/command_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -192,8 +192,9 @@ func testPlan(t *testing.T) *plans.Plan {
192192
// This is just a placeholder so that the plan file can be written
193193
// out. Caller may wish to override it to something more "real"
194194
// where the plan will actually be subsequently applied.
195-
Type: "local",
196-
Config: backendConfigRaw,
195+
Type: "local",
196+
Config: backendConfigRaw,
197+
Workspace: "default",
197198
},
198199
Changes: plans.NewChangesSrc(),
199200

internal/command/graph_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -329,8 +329,9 @@ func TestGraph_applyPhaseSavedPlan(t *testing.T) {
329329
// Doesn't actually matter since we aren't going to activate the backend
330330
// for this command anyway, but we need something here for the plan
331331
// file writer to succeed.
332-
Type: "placeholder",
333-
Config: emptyObj,
332+
Type: "placeholder",
333+
Config: emptyObj,
334+
Workspace: "default",
334335
}
335336
_, configSnap := testModuleWithSnapshot(t, "graph")
336337

internal/command/meta_backend.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -336,6 +336,32 @@ func (m *Meta) selectWorkspace(b backend.Backend) error {
336336
func (m *Meta) BackendForLocalPlan(plan *plans.Plan) (backendrun.OperationsBackend, tfdiags.Diagnostics) {
337337
var diags tfdiags.Diagnostics
338338

339+
// Check the workspace name in the plan matches the current workspace
340+
currentWorkspace, err := m.Workspace()
341+
if err != nil {
342+
diags = diags.Append(fmt.Errorf("error determining current workspace when initializing a backend from the plan file: %w", err))
343+
return nil, diags
344+
}
345+
var plannedWorkspace string
346+
var isCloud bool
347+
switch {
348+
case plan.StateStore != nil:
349+
plannedWorkspace = plan.StateStore.Workspace
350+
isCloud = false
351+
case plan.Backend != nil:
352+
plannedWorkspace = plan.Backend.Workspace
353+
isCloud = plan.Backend.Type == "cloud"
354+
default:
355+
panic(fmt.Sprintf("Workspace data missing from plan file. Current workspace is %q. This is a bug in Terraform and should be reported.", currentWorkspace))
356+
}
357+
if currentWorkspace != plannedWorkspace {
358+
return nil, diags.Append(&errWrongWorkspaceForPlan{
359+
currentWorkspace: currentWorkspace,
360+
plannedWorkspace: plannedWorkspace,
361+
isCloud: isCloud,
362+
})
363+
}
364+
339365
var b backend.Backend
340366
switch {
341367
case plan.StateStore != nil:

internal/command/meta_backend_errors.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,43 @@ import (
99
"github.com/hashicorp/terraform/internal/tfdiags"
1010
)
1111

12+
// errWrongWorkspaceForPlan is a custom error used to alert users that the plan file they are applying
13+
// describes a workspace that doesn't match the currently selected workspace.
14+
//
15+
// This needs to render slightly different errors depending on whether we're using:
16+
// > CE Workspaces (remote-state backends, local backends)
17+
// > HCP Terraform Workspaces (cloud backend)
18+
type errWrongWorkspaceForPlan struct {
19+
plannedWorkspace string
20+
currentWorkspace string
21+
isCloud bool
22+
}
23+
24+
func (e *errWrongWorkspaceForPlan) Error() string {
25+
msg := fmt.Sprintf(`The plan file describes changes to the %q workspace, but the %q workspace is currently in use.
26+
27+
Applying this plan with the incorrect workspace selected could result in state being stored in an unexpected location, or a downstream error when Terraform attempts apply a plan using the other workspace's state.`,
28+
e.plannedWorkspace,
29+
e.currentWorkspace,
30+
)
31+
32+
// For users to understand what's happened and how to correct it we'll give some guidance,
33+
// but that guidance depends on whether a cloud backend is in use or not.
34+
if e.isCloud {
35+
// When using the cloud backend the solution is to focus on the cloud block and running init
36+
msg = msg + fmt.Sprintf(` If you'd like to continue to use the plan file, make sure the cloud block in your configuration contains the workspace name %q.
37+
In future, make sure your cloud block is correct and unchanged since the last time you performed "terraform init" before creating a plan.`, e.plannedWorkspace)
38+
} else {
39+
// When using the backend block the solution is to not select a different workspace
40+
// between plan and apply operations.
41+
msg = msg + fmt.Sprintf(` If you'd like to continue to use the plan file, you must run "terraform workspace select %s" to select the matching workspace.
42+
In future make sure the selected workspace is not changed between creating and applying a plan file.
43+
`, e.plannedWorkspace)
44+
}
45+
46+
return msg
47+
}
48+
1249
// errBackendLocalRead is a custom error used to alert users that state
1350
// files on their local filesystem were not erased successfully after
1451
// migrating that state to a remote-state backend.

internal/command/meta_backend_test.go

Lines changed: 106 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1908,6 +1908,111 @@ func TestMetaBackend_planLocalMatch(t *testing.T) {
19081908
}
19091909
}
19101910

1911+
// A plan that contains a workspace that isn't the currently selected workspace
1912+
func TestMetaBackend_planLocal_mismatchedWorkspace(t *testing.T) {
1913+
t.Run("local backend", func(t *testing.T) {
1914+
td := t.TempDir()
1915+
t.Chdir(td)
1916+
1917+
backendConfigBlock := cty.ObjectVal(map[string]cty.Value{
1918+
"path": cty.NullVal(cty.String),
1919+
"workspace_dir": cty.NullVal(cty.String),
1920+
})
1921+
backendConfigRaw, err := plans.NewDynamicValue(backendConfigBlock, backendConfigBlock.Type())
1922+
if err != nil {
1923+
t.Fatal(err)
1924+
}
1925+
planWorkspace := "default"
1926+
plan := &plans.Plan{
1927+
Backend: &plans.Backend{
1928+
Type: "local",
1929+
Config: backendConfigRaw,
1930+
Workspace: planWorkspace,
1931+
},
1932+
}
1933+
1934+
// Setup the meta
1935+
m := testMetaBackend(t, nil)
1936+
otherWorkspace := "foobar"
1937+
err = m.SetWorkspace(otherWorkspace)
1938+
if err != nil {
1939+
t.Fatalf("error in test setup: %s", err)
1940+
}
1941+
1942+
// Get the backend
1943+
_, diags := m.BackendForLocalPlan(plan)
1944+
if !diags.HasErrors() {
1945+
t.Fatalf("expected an error but got none: %s", diags.ErrWithWarnings())
1946+
}
1947+
expectedMsgs := []string{
1948+
fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently in use",
1949+
planWorkspace,
1950+
otherWorkspace,
1951+
),
1952+
fmt.Sprintf("terraform workspace select %s", planWorkspace),
1953+
}
1954+
for _, msg := range expectedMsgs {
1955+
if !strings.Contains(diags.Err().Error(), msg) {
1956+
t.Fatalf("expected error to include %q, but got:\n%s",
1957+
msg,
1958+
diags.Err())
1959+
}
1960+
}
1961+
})
1962+
1963+
t.Run("cloud backend", func(t *testing.T) {
1964+
td := t.TempDir()
1965+
t.Chdir(td)
1966+
1967+
planWorkspace := "prod"
1968+
cloudConfigBlock := cty.ObjectVal(map[string]cty.Value{
1969+
"organization": cty.StringVal("hashicorp"),
1970+
"workspaces": cty.ObjectVal(map[string]cty.Value{
1971+
"name": cty.StringVal(planWorkspace),
1972+
}),
1973+
})
1974+
cloudConfigRaw, err := plans.NewDynamicValue(cloudConfigBlock, cloudConfigBlock.Type())
1975+
if err != nil {
1976+
t.Fatal(err)
1977+
}
1978+
plan := &plans.Plan{
1979+
Backend: &plans.Backend{
1980+
Type: "cloud",
1981+
Config: cloudConfigRaw,
1982+
Workspace: planWorkspace,
1983+
},
1984+
}
1985+
1986+
// Setup the meta
1987+
m := testMetaBackend(t, nil)
1988+
otherWorkspace := "foobar"
1989+
err = m.SetWorkspace(otherWorkspace)
1990+
if err != nil {
1991+
t.Fatalf("error in test setup: %s", err)
1992+
}
1993+
1994+
// Get the backend
1995+
_, diags := m.BackendForLocalPlan(plan)
1996+
if !diags.HasErrors() {
1997+
t.Fatalf("expected an error but got none: %s", diags.ErrWithWarnings())
1998+
}
1999+
expectedMsgs := []string{
2000+
fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently in use",
2001+
planWorkspace,
2002+
otherWorkspace,
2003+
),
2004+
fmt.Sprintf(`If you'd like to continue to use the plan file, make sure the cloud block in your configuration contains the workspace name %q`, planWorkspace),
2005+
}
2006+
for _, msg := range expectedMsgs {
2007+
if !strings.Contains(diags.Err().Error(), msg) {
2008+
t.Fatalf("expected error to include `%s`, but got:\n%s",
2009+
msg,
2010+
diags.Err())
2011+
}
2012+
}
2013+
})
2014+
}
2015+
19112016
// init a backend using -backend-config options multiple times
19122017
func TestMetaBackend_configureBackendWithExtra(t *testing.T) {
19132018
// Create a temporary working directory that is empty
@@ -2060,7 +2165,6 @@ func TestBackendFromState(t *testing.T) {
20602165
}
20612166

20622167
func Test_determineInitReason(t *testing.T) {
2063-
20642168
cases := map[string]struct {
20652169
cloudMode cloud.ConfigChangeMode
20662170
backendState workdir.BackendStateFile
@@ -2270,7 +2374,6 @@ func TestMetaBackend_configureStateStoreVariableUse(t *testing.T) {
22702374
if !strings.Contains(err.Err().Error(), tc.wantErr) {
22712375
t.Fatalf("error should include %q, got: %s", tc.wantErr, err.Err())
22722376
}
2273-
22742377
})
22752378
}
22762379
}
@@ -2752,7 +2855,7 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) {
27522855

27532856
t.Run("error - no config present", func(t *testing.T) {
27542857
opts := &BackendOpts{
2755-
StateStoreConfig: nil, //unset
2858+
StateStoreConfig: nil, // unset
27562859
Init: true,
27572860
Locks: locks,
27582861
}
@@ -2938,7 +3041,6 @@ func Test_getStateStorageProviderVersion(t *testing.T) {
29383041
}
29393042

29403043
func TestMetaBackend_prepareBackend(t *testing.T) {
2941-
29423044
t.Run("it returns a cloud backend from cloud backend config", func(t *testing.T) {
29433045
// Create a temporary working directory with cloud configuration in
29443046
td := t.TempDir()

0 commit comments

Comments
 (0)