Skip to content
5 changes: 5 additions & 0 deletions .changes/v1.15/BUG FIXES-20251201-114950.yaml
Original file line number Diff line number Diff line change
@@ -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"
5 changes: 3 additions & 2 deletions internal/backend/local/backend_local_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
40 changes: 24 additions & 16 deletions internal/backend/local/backend_plan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
5 changes: 3 additions & 2 deletions internal/command/apply_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
})
Expand Down
5 changes: 3 additions & 2 deletions internal/command/command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(),

Expand Down
5 changes: 3 additions & 2 deletions internal/command/graph_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down
16 changes: 16 additions & 0 deletions internal/command/meta_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand Down
38 changes: 38 additions & 0 deletions internal/command/meta_backend_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
101 changes: 101 additions & 0 deletions internal/command/meta_backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down