Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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

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 @@ -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(),
})
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
26 changes: 26 additions & 0 deletions internal/command/meta_backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -337,6 +337,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:
Expand Down
37 changes: 37 additions & 0 deletions internal/command/meta_backend_errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
110 changes: 106 additions & 4 deletions internal/command/meta_backend_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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())
}

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