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>
pull/38077/head
Sarah French 3 months ago committed by GitHub
parent 379fa79c3e
commit 02a4ddce1b
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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"

@ -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(),

@ -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

@ -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(),
})

@ -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(),

@ -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")

@ -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:

@ -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.

@ -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()

Loading…
Cancel
Save