From 97c921ab225b88f8542bcc1e88a6173fb0d1633c Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 1 Dec 2025 11:47:49 +0000 Subject: [PATCH 1/7] fix: Fail apply command if the plan file was generated for a workspace that isn't the selected workspace. --- internal/command/meta_backend.go | 15 ++++++++ internal/command/meta_backend_errors.go | 21 +++++++++++ internal/command/meta_backend_test.go | 46 +++++++++++++++++++++++++ 3 files changed, 82 insertions(+) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 61e11dc6b35c..b41b8685586c 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -333,6 +333,21 @@ func (m *Meta) selectWorkspace(b backend.Backend) error { func (m *Meta) BackendForLocalPlan(settings plans.Backend) (backendrun.OperationsBackend, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics + // Check the workspace name in the plan matches the current workspace + w, err := m.Workspace() + if err != nil { + diags = diags.Append(fmt.Errorf("error determining current workspace when initializing a backend from the plan file: %w", err)) + return nil, diags + } + if w != settings.Workspace { + diags = diags.Append(&errWrongWorkspaceForPlan{ + currentWorkspace: w, + plannedWorkspace: settings.Workspace, + }) + return nil, diags + } + + // Proceed with initializing the backend from the configuration in the plan file f := backendInit.Backend(settings.Type) if f == nil { diags = diags.Append(errBackendSavedUnknown{settings.Type}) diff --git a/internal/command/meta_backend_errors.go b/internal/command/meta_backend_errors.go index 74314bf752a3..9b98f6783a1e 100644 --- a/internal/command/meta_backend_errors.go +++ b/internal/command/meta_backend_errors.go @@ -9,6 +9,27 @@ import ( "github.com/hashicorp/terraform/internal/tfdiags" ) +// errWrongWorkspaceForPlan is a custom error used to alert users that the plan file they are applying +// describes a workspace that doesn't match the currently selected workspace. +type errWrongWorkspaceForPlan struct { + plannedWorkspace string + currentWorkspace string +} + +func (e *errWrongWorkspaceForPlan) Error() string { + return fmt.Sprintf(`The plan file describes changes to the %q workspace, but the %q workspace is currently selected in the working directory. + +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. + +If you'd like to continue to use the plan file, you must run "terraform workspace select %s" to select the correct workspace. +In future make sure the selected workspace is not changed between creating and applying a plan file.`, + e.plannedWorkspace, + e.currentWorkspace, + e.plannedWorkspace, + ) +} + // errBackendLocalRead is a custom error used to alert users that state // files on their local filesystem were not erased successfully after // migrating that state to a remote-state backend. diff --git a/internal/command/meta_backend_test.go b/internal/command/meta_backend_test.go index 05803622cbb1..088048fa7ab0 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -1837,6 +1837,52 @@ func TestMetaBackend_planLocalMatch(t *testing.T) { } } +// A plan that contains a workspace that isn't the currently selected workspace +func TestMetaBackend_planLocal_mismatchedWorkspace(t *testing.T) { + // Create a temporary working directory that is empty + td := t.TempDir() + testCopyDir(t, testFixturePath("backend-plan-local"), td) + t.Chdir(td) + + backendConfigBlock := cty.ObjectVal(map[string]cty.Value{ + "path": cty.NullVal(cty.String), + "workspace_dir": cty.NullVal(cty.String), + }) + backendConfigRaw, err := plans.NewDynamicValue(backendConfigBlock, backendConfigBlock.Type()) + if err != nil { + t.Fatal(err) + } + defaultWorkspace := "default" + backendConfig := plans.Backend{ + Type: "local", + Config: backendConfigRaw, + Workspace: defaultWorkspace, + } + + // Setup the meta + m := testMetaBackend(t, nil) + selectedWorkspace := "foobar" + err = m.SetWorkspace(selectedWorkspace) + if err != nil { + t.Fatalf("error in test setup: %s", err) + } + + // Get the backend + _, diags := m.BackendForLocalPlan(backendConfig) + if !diags.HasErrors() { + t.Fatalf("expected an error but got none: %s", diags.ErrWithWarnings()) + } + expectedMsg := fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently selected in the working directory", + defaultWorkspace, + selectedWorkspace, + ) + if !strings.Contains(diags.Err().Error(), expectedMsg) { + t.Fatalf("expected error to include %q, but got:\n%s", + expectedMsg, + diags.Err()) + } +} + // init a backend using -backend-config options multiple times func TestMetaBackend_configureBackendWithExtra(t *testing.T) { // Create a temporary working directory that is empty From d57756015c55f108f8ebedbee5e167d46e4f1b9e Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 1 Dec 2025 11:49:59 +0000 Subject: [PATCH 2/7] Add change file --- .changes/v1.15/BUG FIXES-20251201-114950.yaml | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changes/v1.15/BUG FIXES-20251201-114950.yaml diff --git a/.changes/v1.15/BUG FIXES-20251201-114950.yaml b/.changes/v1.15/BUG FIXES-20251201-114950.yaml new file mode 100644 index 000000000000..cfa8ded58b62 --- /dev/null +++ b/.changes/v1.15/BUG FIXES-20251201-114950.yaml @@ -0,0 +1,5 @@ +kind: BUG FIXES +body: 'apply: Terraform will raise an explicit error if a plan file intended for one workspace is applied against another workspace' +time: 2025-12-01T11:49:50.360928Z +custom: + Issue: "37954" From 29ee266f20eeb7459269fcc38cb69292737e4f1e Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 1 Dec 2025 13:39:53 +0000 Subject: [PATCH 3/7] test: Update test helper to include Workspace name in plan representation --- internal/command/command_test.go | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/internal/command/command_test.go b/internal/command/command_test.go index 2c0486dca33c..7c0609388b45 100644 --- a/internal/command/command_test.go +++ b/internal/command/command_test.go @@ -192,8 +192,9 @@ func testPlan(t *testing.T) *plans.Plan { // This is just a placeholder so that the plan file can be written // out. Caller may wish to override it to something more "real" // where the plan will actually be subsequently applied. - Type: "local", - Config: backendConfigRaw, + Type: "local", + Config: backendConfigRaw, + Workspace: "default", }, Changes: plans.NewChangesSrc(), From 317492fa79074e9a2d5cbb8f53dfccb86a3466e6 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 1 Dec 2025 13:40:21 +0000 Subject: [PATCH 4/7] fix: Make error message more generic, so is applicable to backend and cloud blocks. --- internal/command/meta_backend_errors.go | 2 +- internal/command/meta_backend_test.go | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/command/meta_backend_errors.go b/internal/command/meta_backend_errors.go index 9b98f6783a1e..e0f312f5a508 100644 --- a/internal/command/meta_backend_errors.go +++ b/internal/command/meta_backend_errors.go @@ -17,7 +17,7 @@ type errWrongWorkspaceForPlan struct { } func (e *errWrongWorkspaceForPlan) Error() string { - return fmt.Sprintf(`The plan file describes changes to the %q workspace, but the %q workspace is currently selected in the working directory. + return fmt.Sprintf(`The plan file describes changes to the %q workspace, but the %q workspace is currently in use. 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. diff --git a/internal/command/meta_backend_test.go b/internal/command/meta_backend_test.go index 088048fa7ab0..7fdd8d7fea05 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -1872,7 +1872,7 @@ func TestMetaBackend_planLocal_mismatchedWorkspace(t *testing.T) { if !diags.HasErrors() { t.Fatalf("expected an error but got none: %s", diags.ErrWithWarnings()) } - expectedMsg := fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently selected in the working directory", + expectedMsg := fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently in use", defaultWorkspace, selectedWorkspace, ) From f0e85c2ec3f71f9f94104aa49ce9909320c59dd5 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 1 Dec 2025 15:51:16 +0000 Subject: [PATCH 5/7] fix: Make error message specific to backend or cloud block --- internal/command/meta_backend.go | 1 + internal/command/meta_backend_errors.go | 29 ++++++++++++++++++++----- 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index b41b8685586c..9f38f09db2b2 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -343,6 +343,7 @@ func (m *Meta) BackendForLocalPlan(settings plans.Backend) (backendrun.Operation diags = diags.Append(&errWrongWorkspaceForPlan{ currentWorkspace: w, plannedWorkspace: settings.Workspace, + isCloud: settings.Type == "cloud", }) return nil, diags } diff --git a/internal/command/meta_backend_errors.go b/internal/command/meta_backend_errors.go index e0f312f5a508..02d966a3070e 100644 --- a/internal/command/meta_backend_errors.go +++ b/internal/command/meta_backend_errors.go @@ -11,23 +11,40 @@ import ( // errWrongWorkspaceForPlan is a custom error used to alert users that the plan file they are applying // describes a workspace that doesn't match the currently selected workspace. +// +// This needs to render slightly different errors depending on whether we're using: +// > CE Workspaces (remote-state backends, local backends) +// > HCP Terraform Workspaces (cloud backend) type errWrongWorkspaceForPlan struct { plannedWorkspace string currentWorkspace string + isCloud bool } func (e *errWrongWorkspaceForPlan) Error() string { - return fmt.Sprintf(`The plan file describes changes to the %q workspace, but the %q workspace is currently in use. + msg := fmt.Sprintf(`The plan file describes changes to the %q workspace, but the %q workspace is currently in use. 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. - -If you'd like to continue to use the plan file, you must run "terraform workspace select %s" to select the correct workspace. -In future make sure the selected workspace is not changed between creating and applying a plan file.`, +when Terraform attempts apply a plan using the other workspace's state.`, e.plannedWorkspace, e.currentWorkspace, - e.plannedWorkspace, ) + + // For users to understand what's happened and how to correct it we'll give some guidance, + // but that guidance depends on whether a cloud backend is in use or not. + if e.isCloud { + // When using the cloud backend the solution is to focus on the cloud block and running init + 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. +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) + } else { + // When using the backend block the solution is to not select a different workspace + // between plan and apply operations. + 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. +In future make sure the selected workspace is not changed between creating and applying a plan file. +`, e.plannedWorkspace) + } + + return msg } // errBackendLocalRead is a custom error used to alert users that state From 5121b2bc576b355519ae5a250aad3ed02fd6c1f3 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 1 Dec 2025 15:52:08 +0000 Subject: [PATCH 6/7] test: Add separate tests for backend/cloud usage --- internal/command/meta_backend_test.go | 131 ++++++++++++++++++-------- 1 file changed, 93 insertions(+), 38 deletions(-) diff --git a/internal/command/meta_backend_test.go b/internal/command/meta_backend_test.go index 7fdd8d7fea05..634e4763de62 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -1839,48 +1839,103 @@ func TestMetaBackend_planLocalMatch(t *testing.T) { // A plan that contains a workspace that isn't the currently selected workspace func TestMetaBackend_planLocal_mismatchedWorkspace(t *testing.T) { - // Create a temporary working directory that is empty - td := t.TempDir() - testCopyDir(t, testFixturePath("backend-plan-local"), td) - t.Chdir(td) - backendConfigBlock := cty.ObjectVal(map[string]cty.Value{ - "path": cty.NullVal(cty.String), - "workspace_dir": cty.NullVal(cty.String), + t.Run("local backend", func(t *testing.T) { + td := t.TempDir() + t.Chdir(td) + + backendConfigBlock := cty.ObjectVal(map[string]cty.Value{ + "path": cty.NullVal(cty.String), + "workspace_dir": cty.NullVal(cty.String), + }) + backendConfigRaw, err := plans.NewDynamicValue(backendConfigBlock, backendConfigBlock.Type()) + if err != nil { + t.Fatal(err) + } + planWorkspace := "default" + backendConfig := plans.Backend{ + Type: "local", + Config: backendConfigRaw, + Workspace: planWorkspace, + } + + // Setup the meta + m := testMetaBackend(t, nil) + otherWorkspace := "foobar" + err = m.SetWorkspace(otherWorkspace) + if err != nil { + t.Fatalf("error in test setup: %s", err) + } + + // Get the backend + _, diags := m.BackendForLocalPlan(backendConfig) + if !diags.HasErrors() { + t.Fatalf("expected an error but got none: %s", diags.ErrWithWarnings()) + } + expectedMsgs := []string{ + fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently in use", + planWorkspace, + otherWorkspace, + ), + fmt.Sprintf("terraform workspace select %s", planWorkspace), + } + for _, msg := range expectedMsgs { + if !strings.Contains(diags.Err().Error(), msg) { + t.Fatalf("expected error to include %q, but got:\n%s", + msg, + diags.Err()) + } + } }) - backendConfigRaw, err := plans.NewDynamicValue(backendConfigBlock, backendConfigBlock.Type()) - if err != nil { - t.Fatal(err) - } - defaultWorkspace := "default" - backendConfig := plans.Backend{ - Type: "local", - Config: backendConfigRaw, - Workspace: defaultWorkspace, - } - // Setup the meta - m := testMetaBackend(t, nil) - selectedWorkspace := "foobar" - err = m.SetWorkspace(selectedWorkspace) - if err != nil { - t.Fatalf("error in test setup: %s", err) - } + t.Run("cloud backend", func(t *testing.T) { + td := t.TempDir() + t.Chdir(td) - // Get the backend - _, diags := m.BackendForLocalPlan(backendConfig) - if !diags.HasErrors() { - t.Fatalf("expected an error but got none: %s", diags.ErrWithWarnings()) - } - expectedMsg := fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently in use", - defaultWorkspace, - selectedWorkspace, - ) - if !strings.Contains(diags.Err().Error(), expectedMsg) { - t.Fatalf("expected error to include %q, but got:\n%s", - expectedMsg, - diags.Err()) - } + planWorkspace := "prod" + cloudConfigBlock := cty.ObjectVal(map[string]cty.Value{ + "organization": cty.StringVal("hashicorp"), + "workspaces": cty.ObjectVal(map[string]cty.Value{ + "name": cty.StringVal(planWorkspace), + })}) + cloudConfigRaw, err := plans.NewDynamicValue(cloudConfigBlock, cloudConfigBlock.Type()) + if err != nil { + t.Fatal(err) + } + backendConfig := plans.Backend{ + Type: "cloud", + Config: cloudConfigRaw, + Workspace: planWorkspace, + } + + // Setup the meta + m := testMetaBackend(t, nil) + otherWorkspace := "foobar" + err = m.SetWorkspace(otherWorkspace) + if err != nil { + t.Fatalf("error in test setup: %s", err) + } + + // Get the backend + _, diags := m.BackendForLocalPlan(backendConfig) + if !diags.HasErrors() { + t.Fatalf("expected an error but got none: %s", diags.ErrWithWarnings()) + } + expectedMsgs := []string{ + fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently in use", + planWorkspace, + otherWorkspace, + ), + 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), + } + for _, msg := range expectedMsgs { + if !strings.Contains(diags.Err().Error(), msg) { + t.Fatalf("expected error to include `%s`, but got:\n%s", + msg, + diags.Err()) + } + } + }) } // init a backend using -backend-config options multiple times From c06262eed5a557d7fd3023ccb50ade24e609c420 Mon Sep 17 00:00:00 2001 From: Sarah French Date: Mon, 1 Dec 2025 16:00:00 +0000 Subject: [PATCH 7/7] test: Update remaining tests to include a value for Workspace in mocked plans --- internal/backend/local/backend_local_test.go | 5 ++- internal/backend/local/backend_plan_test.go | 40 ++++++++++++-------- internal/command/apply_test.go | 5 ++- internal/command/graph_test.go | 5 ++- 4 files changed, 33 insertions(+), 22 deletions(-) diff --git a/internal/backend/local/backend_local_test.go b/internal/backend/local/backend_local_test.go index 2e84d01f6a64..83c0c17cbd9b 100644 --- a/internal/backend/local/backend_local_test.go +++ b/internal/backend/local/backend_local_test.go @@ -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(), diff --git a/internal/backend/local/backend_plan_test.go b/internal/backend/local/backend_plan_test.go index ace870fe5e29..01f43b4e3838 100644 --- a/internal/backend/local/backend_plan_test.go +++ b/internal/backend/local/backend_plan_test.go @@ -206,8 +206,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 { @@ -262,8 +263,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 { @@ -304,8 +306,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 { @@ -383,8 +386,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 { @@ -474,8 +478,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 { @@ -565,8 +570,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) @@ -617,8 +623,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) @@ -689,8 +696,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 diff --git a/internal/command/apply_test.go b/internal/command/apply_test.go index 38b416f711f2..ce0caf45a8f7 100644 --- a/internal/command/apply_test.go +++ b/internal/command/apply_test.go @@ -822,8 +822,9 @@ func TestApply_plan_remoteState(t *testing.T) { } planPath := testPlanFile(t, snap, state, &plans.Plan{ Backend: &plans.Backend{ - Type: "http", - Config: backendConfigRaw, + Type: "http", + Config: backendConfigRaw, + Workspace: "default", }, Changes: plans.NewChangesSrc(), }) diff --git a/internal/command/graph_test.go b/internal/command/graph_test.go index dc98f74c71f7..8193614329de 100644 --- a/internal/command/graph_test.go +++ b/internal/command/graph_test.go @@ -329,8 +329,9 @@ func TestGraph_applyPhaseSavedPlan(t *testing.T) { // Doesn't actually matter since we aren't going to activate the backend // for this command anyway, but we need something here for the plan // file writer to succeed. - Type: "placeholder", - Config: emptyObj, + Type: "placeholder", + Config: emptyObj, + Workspace: "default", } _, configSnap := testModuleWithSnapshot(t, "graph")