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" 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/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(), 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") diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 61e11dc6b35c..9f38f09db2b2 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -333,6 +333,22 @@ 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, + isCloud: settings.Type == "cloud", + }) + 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..02d966a3070e 100644 --- a/internal/command/meta_backend_errors.go +++ b/internal/command/meta_backend_errors.go @@ -9,6 +9,44 @@ 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 05803622cbb1..634e4763de62 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -1837,6 +1837,107 @@ 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" + 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()) + } + } + }) + + 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) + } + 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 func TestMetaBackend_configureBackendWithExtra(t *testing.T) { // Create a temporary working directory that is empty