From 02a4ddce1bc1b91772cb573a16e004cb8f595d31 Mon Sep 17 00:00:00 2001 From: Sarah French <15078782+SarahFrench@users.noreply.github.com> Date: Tue, 3 Feb 2026 15:29:58 +0000 Subject: [PATCH] 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 * fix: Panic when a plan file has missing workspace data * test: Update test to match changes in error text --------- Co-authored-by: Radek Simko --- .changes/v1.15/BUG FIXES-20251201-114950.yaml | 5 + internal/backend/local/backend_local_test.go | 5 +- internal/backend/local/backend_plan_test.go | 40 ++++--- internal/command/apply_test.go | 5 +- internal/command/command_test.go | 5 +- internal/command/graph_test.go | 5 +- internal/command/meta_backend.go | 26 +++++ internal/command/meta_backend_errors.go | 37 ++++++ internal/command/meta_backend_test.go | 110 +++++++++++++++++- 9 files changed, 210 insertions(+), 28 deletions(-) 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 0000000000..cfa8ded58b --- /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" diff --git a/internal/backend/local/backend_local_test.go b/internal/backend/local/backend_local_test.go index 2e84d01f6a..83c0c17cbd 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 1f6a10f250..f7ecb0f6f4 100644 --- a/internal/backend/local/backend_plan_test.go +++ b/internal/backend/local/backend_plan_test.go @@ -207,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 { @@ -263,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 { @@ -305,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 { @@ -384,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 { @@ -475,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 { @@ -566,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) @@ -618,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) @@ -690,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 diff --git a/internal/command/apply_test.go b/internal/command/apply_test.go index 2e7e22a850..b3c91dbef4 100644 --- a/internal/command/apply_test.go +++ b/internal/command/apply_test.go @@ -1087,8 +1087,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/command_test.go b/internal/command/command_test.go index 2c0486dca3..7c0609388b 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(), diff --git a/internal/command/graph_test.go b/internal/command/graph_test.go index dc98f74c71..8193614329 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") diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index ff990a7dcd..4a1ddc4cf6 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -336,6 +336,32 @@ func (m *Meta) selectWorkspace(b backend.Backend) error { func (m *Meta) BackendForLocalPlan(plan *plans.Plan) (backendrun.OperationsBackend, tfdiags.Diagnostics) { var diags tfdiags.Diagnostics + // Check the workspace name in the plan matches the current workspace + currentWorkspace, 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 + } + var plannedWorkspace string + var isCloud bool + switch { + case plan.StateStore != nil: + plannedWorkspace = plan.StateStore.Workspace + isCloud = false + case plan.Backend != nil: + plannedWorkspace = plan.Backend.Workspace + isCloud = plan.Backend.Type == "cloud" + default: + panic(fmt.Sprintf("Workspace data missing from plan file. Current workspace is %q. This is a bug in Terraform and should be reported.", currentWorkspace)) + } + if currentWorkspace != plannedWorkspace { + return nil, diags.Append(&errWrongWorkspaceForPlan{ + currentWorkspace: currentWorkspace, + plannedWorkspace: plannedWorkspace, + isCloud: isCloud, + }) + } + var b backend.Backend switch { case plan.StateStore != nil: diff --git a/internal/command/meta_backend_errors.go b/internal/command/meta_backend_errors.go index 3b79819591..50868255ee 100644 --- a/internal/command/meta_backend_errors.go +++ b/internal/command/meta_backend_errors.go @@ -9,6 +9,43 @@ 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. +// +// 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 { + 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.`, + e.plannedWorkspace, + e.currentWorkspace, + ) + + // 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 // 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 1c06899471..6269c6ec1d 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -1908,6 +1908,111 @@ 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) { + 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" + plan := &plans.Plan{ + Backend: &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(plan) + 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()) + } + } + }) + + t.Run("cloud backend", func(t *testing.T) { + td := t.TempDir() + t.Chdir(td) + + 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) + } + plan := &plans.Plan{ + Backend: &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(plan) + 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 func TestMetaBackend_configureBackendWithExtra(t *testing.T) { // Create a temporary working directory that is empty @@ -2060,7 +2165,6 @@ func TestBackendFromState(t *testing.T) { } func Test_determineInitReason(t *testing.T) { - cases := map[string]struct { cloudMode cloud.ConfigChangeMode backendState workdir.BackendStateFile @@ -2270,7 +2374,6 @@ func TestMetaBackend_configureStateStoreVariableUse(t *testing.T) { if !strings.Contains(err.Err().Error(), tc.wantErr) { t.Fatalf("error should include %q, got: %s", tc.wantErr, err.Err()) } - }) } } @@ -2752,7 +2855,7 @@ func TestMetaBackend_stateStoreConfig(t *testing.T) { t.Run("error - no config present", func(t *testing.T) { opts := &BackendOpts{ - StateStoreConfig: nil, //unset + StateStoreConfig: nil, // unset Init: true, Locks: locks, } @@ -2938,7 +3041,6 @@ func Test_getStateStorageProviderVersion(t *testing.T) { } func TestMetaBackend_prepareBackend(t *testing.T) { - t.Run("it returns a cloud backend from cloud backend config", func(t *testing.T) { // Create a temporary working directory with cloud configuration in td := t.TempDir()