diff --git a/internal/command/e2etest/primary_test.go b/internal/command/e2etest/primary_test.go index ebffa9c37d75..86cfafbe5dbd 100644 --- a/internal/command/e2etest/primary_test.go +++ b/internal/command/e2etest/primary_test.go @@ -149,6 +149,343 @@ func TestPrimarySeparatePlan(t *testing.T) { } +// There is NO error if you apply a plan against the wrong workspace and: +// 1) The plan doesn't depend on prior state (only adds resources) +// 2) there's no state for that 'wrong' workspace yet +func TestPrimarySeparatePlan_incorrectWorkspace_noPriorState(t *testing.T) { + t.Parallel() + + // This test reaches out to releases.hashicorp.com to download the + // template and null providers, so it can only run if network access is + // allowed. + skipIfCannotAccessNetwork(t) + + fixturePath := filepath.Join("testdata", "full-workflow") + tf := e2e.NewBinary(t, terraformBin, fixturePath) + + //// INIT + stdout, stderr, err := tf.Run("init", "-no-color") + if err != nil { + t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) + } + + // Make sure we actually downloaded the plugins, rather than picking up + // copies that might be already installed globally on the system. + if !strings.Contains(stdout, "Installing hashicorp/null v") { + t.Errorf("null provider download message is missing from init output:\n%s", stdout) + t.Logf("(this can happen if you have a copy of the plugin in one of the global plugin search dirs)") + } + + //// PLAN + stdout, stderr, err = tf.Run("plan", "-out=tfplan", "-no-color") + if err != nil { + t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "1 to add, 0 to change, 0 to destroy") { + t.Errorf("incorrect plan tally; want 1 to add:\n%s", stdout) + } + + if !strings.Contains(stdout, "Saved the plan to: tfplan") { + t.Errorf("missing \"Saved the plan to...\" message in plan output\n%s", stdout) + } + if !strings.Contains(stdout, "terraform apply \"tfplan\"") { + t.Errorf("missing next-step instruction in plan output\n%s", stdout) + } + + plan, err := tf.Plan("tfplan") + if err != nil { + t.Fatalf("failed to read plan file: %s", err) + } + + if plan.Backend.Workspace != "default" { + t.Fatalf("expected plan to contain Workspace %q, got %q", "default", plan.Backend.Workspace) + } + + // Create and select a workspace that doesn't match the plan made above + newWorkspace := "foobar" + stdout, stderr, err = tf.Run("workspace", "new", newWorkspace, "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + expectedMsg := fmt.Sprintf("Created and switched to workspace %q!", newWorkspace) + if !strings.Contains(stdout, expectedMsg) { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + } + + //// APPLY + stdout, stderr, err = tf.Run("apply", "tfplan", "-no-color") + if err != nil { + t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Resources: 1 added, 0 changed, 0 destroyed") { + t.Errorf("incorrect apply tally; want 1 added:\n%s", stdout) + } + + // Read the state saved for the custom workspace 'foobar' + filename := fmt.Sprintf("%s/terraform.tfstate.d/%s/terraform.tfstate", tf.WorkDir(), newWorkspace) + f, err := os.OpenFile(filename, os.O_RDWR, 0666) + if err != nil { + t.Fatalf("Error opening file at %s: %s", filename, err) + } + defer f.Close() + + stateFile, err := statefile.Read(f) + if err != nil { + t.Fatalf("Error reading statefile: %s", err) + } + state := stateFile.State + + stateResources := state.RootModule().Resources + var gotResources []string + for n := range stateResources { + gotResources = append(gotResources, n) + } + sort.Strings(gotResources) + + wantResources := []string{ + "null_resource.test", + } + + if !reflect.DeepEqual(gotResources, wantResources) { + t.Errorf("wrong resources in state\ngot: %#v\nwant: %#v", gotResources, wantResources) + } + + //// DESTROY + stdout, stderr, err = tf.Run("destroy", "-auto-approve", "-no-color") + if err != nil { + t.Fatalf("unexpected destroy error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Resources: 1 destroyed") { + t.Errorf("incorrect destroy tally; want 1 destroyed:\n%s", stdout) + } + + f, err = os.OpenFile(filename, os.O_RDWR, 0666) + if err != nil { + t.Fatalf("Error opening file at %s: %s", filename, err) + } + defer f.Close() + + stateFile, err = statefile.Read(f) + if err != nil { + t.Fatalf("Error reading statefile: %s", err) + } + state = stateFile.State + + stateResources = state.RootModule().Resources + if len(stateResources) != 0 { + t.Errorf("wrong resources in state after destroy; want none, but still have:%s", spew.Sdump(stateResources)) + } + +} + +// There is an error if you apply a plan against the wrong workspace, where that plan is making resources for the first time. +// +// In this case there is a state for the 'wrong' workspace, which is mismatched with the state used for the plan. +func TestPrimarySeparatePlan_incorrectWorkspace_withPriorState(t *testing.T) { + t.Parallel() + + // This test reaches out to releases.hashicorp.com to download the + // template and null providers, so it can only run if network access is + // allowed. + skipIfCannotAccessNetwork(t) + + fixturePath := filepath.Join("testdata", "full-workflow") + tf := e2e.NewBinary(t, terraformBin, fixturePath) + + //// INIT + stdout, stderr, err := tf.Run("init", "-no-color") + if err != nil { + t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) + } + + // Make sure we actually downloaded the plugins, rather than picking up + // copies that might be already installed globally on the system. + if !strings.Contains(stdout, "Installing hashicorp/null v") { + t.Errorf("null provider download message is missing from init output:\n%s", stdout) + t.Logf("(this can happen if you have a copy of the plugin in one of the global plugin search dirs)") + } + + // CREATE WORKSPACE + // + // Create and select a custom workspace + newWorkspace := "foobar" + stdout, stderr, err = tf.Run("workspace", "new", newWorkspace, "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + expectedMsg := fmt.Sprintf("Created and switched to workspace %q!", newWorkspace) + if !strings.Contains(stdout, expectedMsg) { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + } + + //// APPLY + // + // This will make the prior state for the custom workspace + stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color") + if err != nil { + t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Resources: 1 added, 0 changed, 0 destroyed") { + t.Errorf("incorrect apply tally; want 1 added:\n%s", stdout) + } + + //// SELECT WORKSPACE + // + // Select the default workspace, so we can make a plan with that + // workspace baked in. + stdout, stderr, err = tf.Run("workspace", "select", "default", "-no-color") + if err != nil { + t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Switched to workspace \"default\"") { + t.Fatalf("unexpected output:\n%s", stdout) + } + + //// PLAN + stdout, stderr, err = tf.Run("plan", "-out=tfplan", "-no-color") + if err != nil { + t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "1 to add, 0 to change, 0 to destroy") { + t.Errorf("incorrect plan tally; want 1 to add:\n%s", stdout) + } + + if !strings.Contains(stdout, "Saved the plan to: tfplan") { + t.Errorf("missing \"Saved the plan to...\" message in plan output\n%s", stdout) + } + if !strings.Contains(stdout, "terraform apply \"tfplan\"") { + t.Errorf("missing next-step instruction in plan output\n%s", stdout) + } + + plan, err := tf.Plan("tfplan") + if err != nil { + t.Fatalf("failed to read plan file: %s", err) + } + + if plan.Backend.Workspace != "default" { + t.Fatalf("expected plan to contain Workspace %q, got %q", "default", plan.Backend.Workspace) + } + + //// SELECT WORKSPACE + // + // Select the custom workspace, so we can try to use the plan against + // that workspace with its prior state + stdout, stderr, err = tf.Run("workspace", "select", newWorkspace, "-no-color") + if err != nil { + t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Switched to workspace \"foobar\".") { + t.Fatalf("unexpected output:\n%s", stdout) + } + + //// APPLY + // + // With the plan intended for the default workspace + stdout, stderr, err = tf.Run("apply", "tfplan", "-no-color") + if err == nil { + t.Fatalf("expected an error but got none: %s\nstdout:\n%s", err, stdout) + } + + if !strings.Contains(stderr, "Saved plan is stale") { + t.Errorf("expected error to report the plan as stale, got:\n%s", stderr) + } +} + +// There is an error if you apply a plan against the wrong workspace and that plan relies on prior state (makes changes or removes resources) +// +// In this case there is no state for the 'wrong' workspace. +// Also, the error specifies lineage as the reason for the error. +func TestPrimarySeparatePlan_incorrectWorkspace_planChangingExistingResources_noPriorState(t *testing.T) { + t.Parallel() + + // This test reaches out to releases.hashicorp.com to download the + // template and null providers, so it can only run if network access is + // allowed. + skipIfCannotAccessNetwork(t) + + fixturePath := filepath.Join("testdata", "full-workflow") + tf := e2e.NewBinary(t, terraformBin, fixturePath) + + //// INIT + stdout, stderr, err := tf.Run("init", "-no-color") + if err != nil { + t.Fatalf("unexpected init error: %s\nstderr:\n%s", err, stderr) + } + + // Make sure we actually downloaded the plugins, rather than picking up + // copies that might be already installed globally on the system. + if !strings.Contains(stdout, "Installing hashicorp/null v") { + t.Errorf("null provider download message is missing from init output:\n%s", stdout) + t.Logf("(this can happen if you have a copy of the plugin in one of the global plugin search dirs)") + } + + //// APPLY + // + // Create state for the default workspace + stdout, stderr, err = tf.Run("apply", "-auto-approve", "-no-color") + if err != nil { + t.Fatalf("unexpected apply error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Resources: 1 added, 0 changed, 0 destroyed") { + t.Errorf("incorrect apply tally; want 1 added:\n%s", stdout) + } + + //// PLAN + stdout, stderr, err = tf.Run("plan", "-var=name=Sarah", "-out=tfplan", "-no-color") + if err != nil { + t.Fatalf("unexpected plan error: %s\nstderr:\n%s", err, stderr) + } + + if !strings.Contains(stdout, "Plan: 1 to add, 0 to change, 1 to destroy.") { + t.Fatalf("unexpected output: %s", stdout) + } + + if !strings.Contains(stdout, "Saved the plan to: tfplan") { + t.Errorf("missing \"Saved the plan to...\" message in plan output\n%s", stdout) + } + if !strings.Contains(stdout, "terraform apply \"tfplan\"") { + t.Errorf("missing next-step instruction in plan output\n%s", stdout) + } + + plan, err := tf.Plan("tfplan") + if err != nil { + t.Fatalf("failed to read plan file: %s", err) + } + + if plan.Backend.Workspace != "default" { + t.Fatalf("expected plan to contain Workspace %q, got %q", "default", plan.Backend.Workspace) + } + + // Create and select a workspace that doesn't match the plan made above + newWorkspace := "foobar" + stdout, stderr, err = tf.Run("workspace", "new", newWorkspace, "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + expectedMsg := fmt.Sprintf("Created and switched to workspace %q!", newWorkspace) + if !strings.Contains(stdout, expectedMsg) { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + } + + //// APPLY + stdout, stderr, err = tf.Run("apply", "tfplan", "-no-color") + if err == nil { + t.Fatalf("expected error but got none: %s\nstdout:\n%s", err, stdout) + } + + if !strings.Contains(stderr, "different state lineage") { + t.Errorf("expected error to be due to lineage, but got:\n%s", stderr) + } +} + func TestPrimaryChdirOption(t *testing.T) { t.Parallel() diff --git a/internal/command/e2etest/testdata/full-workflow/main.tf b/internal/command/e2etest/testdata/full-workflow/main.tf new file mode 100644 index 000000000000..b0d0b48a1f31 --- /dev/null +++ b/internal/command/e2etest/testdata/full-workflow/main.tf @@ -0,0 +1,14 @@ + +variable "name" { + default = "world" +} + +resource "null_resource" "test" { + triggers = { + greeting = "Hello ${var.name}" + } +} + +output "greeting" { + value = null_resource.test.triggers["greeting"] +} diff --git a/internal/command/meta_backend.go b/internal/command/meta_backend.go index 61e11dc6b35c..b41b8685586c 100644 --- a/internal/command/meta_backend.go +++ b/internal/command/meta_backend.go @@ -333,6 +333,21 @@ 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, + }) + 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..9b98f6783a1e 100644 --- a/internal/command/meta_backend_errors.go +++ b/internal/command/meta_backend_errors.go @@ -9,6 +9,27 @@ 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. +type errWrongWorkspaceForPlan struct { + plannedWorkspace string + currentWorkspace string +} + +func (e *errWrongWorkspaceForPlan) Error() string { + return fmt.Sprintf(`The plan file describes changes to the %q workspace, but the %q workspace is currently selected in the working directory. + +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. + +If you'd like to continue to use the plan file, you must run "terraform workspace select %s" to select the correct workspace. +In future make sure the selected workspace is not changed between creating and applying a plan file.`, + e.plannedWorkspace, + e.currentWorkspace, + e.plannedWorkspace, + ) +} + // 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..088048fa7ab0 100644 --- a/internal/command/meta_backend_test.go +++ b/internal/command/meta_backend_test.go @@ -1837,6 +1837,52 @@ 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) { + // Create a temporary working directory that is empty + td := t.TempDir() + testCopyDir(t, testFixturePath("backend-plan-local"), td) + 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) + } + defaultWorkspace := "default" + backendConfig := plans.Backend{ + Type: "local", + Config: backendConfigRaw, + Workspace: defaultWorkspace, + } + + // Setup the meta + m := testMetaBackend(t, nil) + selectedWorkspace := "foobar" + err = m.SetWorkspace(selectedWorkspace) + 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()) + } + expectedMsg := fmt.Sprintf("The plan file describes changes to the %q workspace, but the %q workspace is currently selected in the working directory", + defaultWorkspace, + selectedWorkspace, + ) + if !strings.Contains(diags.Err().Error(), expectedMsg) { + t.Fatalf("expected error to include %q, but got:\n%s", + expectedMsg, + 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