diff --git a/internal/command/e2etest/pluggable_state_store_test.go b/internal/command/e2etest/pluggable_state_store_test.go new file mode 100644 index 000000000000..330f6f1bdac8 --- /dev/null +++ b/internal/command/e2etest/pluggable_state_store_test.go @@ -0,0 +1,122 @@ +// Copyright (c) HashiCorp, Inc. +// SPDX-License-Identifier: BUSL-1.1 + +package e2etest + +import ( + "fmt" + "os" + "path" + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/terraform/internal/e2e" + "github.com/hashicorp/terraform/internal/getproviders" +) + +func TestPrimary_stateStore_workspaceCmd(t *testing.T) { + if v := os.Getenv("TF_TEST_EXPERIMENTS"); v == "" { + t.Skip("can't run without enabling experiments in the executable terraform binary, enable with TF_TEST_EXPERIMENTS=1") + } + + if !canRunGoBuild { + // We're running in a separate-build-then-run context, so we can't + // currently execute this test which depends on being able to build + // new executable at runtime. + // + // (See the comment on canRunGoBuild's declaration for more information.) + t.Skip("can't run without building a new provider executable") + } + t.Parallel() + + tf := e2e.NewBinary(t, terraformBin, "testdata/full-workflow-with-state-store-fs") + workspaceDirName := "states" // see test fixture value for workspace_dir + + // In order to test integration with PSS we need a provider plugin implementing a state store. + // Here will build the simple6 (built with protocol v6) provider, which implements PSS. + simple6Provider := filepath.Join(tf.WorkDir(), "terraform-provider-simple6") + simple6ProviderExe := e2e.GoBuild("github.com/hashicorp/terraform/internal/provider-simple-v6/main", simple6Provider) + + // Move the provider binaries into a directory that we will point terraform + // to using the -plugin-dir cli flag. + platform := getproviders.CurrentPlatform.String() + hashiDir := "cache/registry.terraform.io/hashicorp/" + if err := os.MkdirAll(tf.Path(hashiDir, "simple6/0.0.1/", platform), os.ModePerm); err != nil { + t.Fatal(err) + } + if err := os.Rename(simple6ProviderExe, tf.Path(hashiDir, "simple6/0.0.1/", platform, "terraform-provider-simple6")); err != nil { + t.Fatal(err) + } + + //// Init + _, stderr, err := tf.Run("init", "-enable-pluggable-state-storage-experiment=true", "-plugin-dir=cache", "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + fi, err := os.Stat(path.Join(tf.WorkDir(), workspaceDirName, "default", "terraform.tfstate")) + if err != nil { + t.Fatalf("failed to open default workspace's state file: %s", err) + } + if fi.Size() == 0 { + t.Fatal("default workspace's state file should not have size 0 bytes") + } + + //// Create Workspace: terraform workspace new + 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) + } + fi, err = os.Stat(path.Join(tf.WorkDir(), workspaceDirName, newWorkspace, "terraform.tfstate")) + if err != nil { + t.Fatalf("failed to open %s workspace's state file: %s", newWorkspace, err) + } + if fi.Size() == 0 { + t.Fatalf("%s workspace's state file should not have size 0 bytes", newWorkspace) + } + + //// List Workspaces: : terraform workspace list + stdout, stderr, err = tf.Run("workspace", "list", "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + if !strings.Contains(stdout, newWorkspace) { + t.Errorf("unexpected output, expected the new %q workspace to be listed present, but it's missing. Got:\n%s", newWorkspace, stdout) + } + + //// Select Workspace: terraform workspace select + selectedWorkspace := "default" + stdout, stderr, err = tf.Run("workspace", "select", selectedWorkspace, "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + expectedMsg = fmt.Sprintf("Switched to workspace %q.", selectedWorkspace) + if !strings.Contains(stdout, expectedMsg) { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + } + + //// Show Workspace: terraform workspace show + stdout, stderr, err = tf.Run("workspace", "show", "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + expectedMsg = fmt.Sprintf("%s\n", selectedWorkspace) + if stdout != expectedMsg { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + } + + //// Delete Workspace: terraform workspace delete + stdout, stderr, err = tf.Run("workspace", "delete", newWorkspace, "-no-color") + if err != nil { + t.Fatalf("unexpected error: %s\nstderr:\n%s", err, stderr) + } + expectedMsg = fmt.Sprintf("Deleted workspace %q!\n", newWorkspace) + if stdout != expectedMsg { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, stdout) + } +} diff --git a/internal/command/e2etest/primary_test.go b/internal/command/e2etest/primary_test.go index 7e6745d44e19..71d170fc558d 100644 --- a/internal/command/e2etest/primary_test.go +++ b/internal/command/e2etest/primary_test.go @@ -4,6 +4,7 @@ package e2etest import ( + "fmt" "os" "path/filepath" "reflect" @@ -251,6 +252,7 @@ func TestPrimary_stateStore(t *testing.T) { t.Parallel() tf := e2e.NewBinary(t, terraformBin, "testdata/full-workflow-with-state-store-fs") + workspaceDirName := "states" // See workspace_dir value in the configuration // In order to test integration with PSS we need a provider plugin implementing a state store. // Here will build the simple6 (built with protocol v6) provider, which implements PSS. @@ -292,7 +294,7 @@ func TestPrimary_stateStore(t *testing.T) { } // Check the statefile saved by the fs state store. - path := "terraform.tfstate.d/default/terraform.tfstate" + path := fmt.Sprintf("%s/default/terraform.tfstate", workspaceDirName) f, err := tf.OpenFile(path) if err != nil { t.Fatalf("unexpected error opening state file %s: %s\nstderr:\n%s", path, err, stderr) diff --git a/internal/command/e2etest/testdata/full-workflow-with-state-store-fs/main.tf b/internal/command/e2etest/testdata/full-workflow-with-state-store-fs/main.tf index d2f5c9b4446f..d2c773a6fca9 100644 --- a/internal/command/e2etest/testdata/full-workflow-with-state-store-fs/main.tf +++ b/internal/command/e2etest/testdata/full-workflow-with-state-store-fs/main.tf @@ -7,6 +7,8 @@ terraform { state_store "simple6_fs" { provider "simple6" {} + + workspace_dir = "states" } } diff --git a/internal/command/workspace_command_test.go b/internal/command/workspace_command_test.go index 5e35ccef413c..937e70f31a1b 100644 --- a/internal/command/workspace_command_test.go +++ b/internal/command/workspace_command_test.go @@ -4,7 +4,7 @@ package command import ( - "io/ioutil" + "fmt" "os" "path/filepath" "strings" @@ -16,11 +16,159 @@ import ( "github.com/hashicorp/terraform/internal/backend" "github.com/hashicorp/terraform/internal/backend/local" "github.com/hashicorp/terraform/internal/backend/remote-state/inmem" + "github.com/hashicorp/terraform/internal/providers" "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/states/statemgr" ) +func TestWorkspace_allCommands_pluggableStateStore(t *testing.T) { + // Create a temporary working directory with pluggable state storage in the config + td := t.TempDir() + testCopyDir(t, testFixturePath("state-store-new"), td) + t.Chdir(td) + + mock := testStateStoreMockWithChunkNegotiation(t, 1000) + newMeta := func(provider providers.Interface) (meta Meta, ui *cli.MockUi, close func()) { + // Assumes the mocked provider is hashicorp/test + providerSource, close := newMockProviderSource(t, map[string][]string{ + "hashicorp/test": {"1.2.3"}, + }) + + ui = new(cli.MockUi) + view, _ := testView(t) + meta = Meta{ + AllowExperimentalFeatures: true, + Ui: ui, + View: view, + testingOverrides: &testingOverrides{ + Providers: map[addrs.Provider]providers.Factory{ + addrs.NewDefaultProvider("test"): providers.FactoryFixed(mock), + }, + }, + ProviderSource: providerSource, + } + return meta, ui, close + } + + //// Init + meta, ui, close := newMeta(mock) + defer close() + intCmd := &InitCommand{ + Meta: meta, + } + args := []string{"-enable-pluggable-state-storage-experiment"} // Needed to test init changes for PSS project + code := intCmd.Run(args) + if code != 0 { + t.Fatalf("bad: %d\n\n%s\n%s", code, ui.ErrorWriter, ui.OutputWriter) + } + // We expect a state to have been created for the default workspace + if _, ok := mock.MockStates["default"]; !ok { + t.Fatal("expected the default workspace to exist, but it didn't") + } + + //// Create Workspace + newWorkspace := "foobar" + + meta, ui, close = newMeta(mock) + defer close() + newCmd := &WorkspaceNewCommand{ + Meta: meta, + } + + current, _ := newCmd.Workspace() + if current != backend.DefaultStateName { + t.Fatal("before creating any custom workspaces, the current workspace should be 'default'") + } + + args = []string{newWorkspace} + code = newCmd.Run(args) + if code != 0 { + t.Fatalf("bad: %d\n\n%s\n%s", code, ui.ErrorWriter, ui.OutputWriter) + } + expectedMsg := fmt.Sprintf("Created and switched to workspace %q!", newWorkspace) + if !strings.Contains(ui.OutputWriter.String(), expectedMsg) { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, ui.OutputWriter) + } + // We expect a state to have been created for the new custom workspace + if _, ok := mock.MockStates[newWorkspace]; !ok { + t.Fatalf("expected the %s workspace to exist, but it didn't", newWorkspace) + } + current, _ = newCmd.Workspace() + if current != newWorkspace { + t.Fatalf("current workspace should be %q, got %q", newWorkspace, current) + } + + //// List Workspaces + meta, ui, close = newMeta(mock) + defer close() + listCmd := &WorkspaceListCommand{ + Meta: meta, + } + args = []string{} + code = listCmd.Run(args) + if code != 0 { + t.Fatalf("bad: %d\n\n%s\n%s", code, ui.ErrorWriter, ui.OutputWriter) + } + if !strings.Contains(ui.OutputWriter.String(), newWorkspace) { + t.Errorf("unexpected output, expected the new %q workspace to be listed present, but it's missing. Got:\n%s", newWorkspace, ui.OutputWriter) + } + + //// Select Workspace + meta, ui, close = newMeta(mock) + defer close() + selCmd := &WorkspaceSelectCommand{ + Meta: meta, + } + selectedWorkspace := backend.DefaultStateName + args = []string{selectedWorkspace} + code = selCmd.Run(args) + if code != 0 { + t.Fatalf("bad: %d\n\n%s\n%s", code, ui.ErrorWriter, ui.OutputWriter) + } + expectedMsg = fmt.Sprintf("Switched to workspace %q.", selectedWorkspace) + if !strings.Contains(ui.OutputWriter.String(), expectedMsg) { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, ui.OutputWriter) + } + + //// Show Workspace + meta, ui, close = newMeta(mock) + defer close() + showCmd := &WorkspaceShowCommand{ + Meta: meta, + } + args = []string{} + code = showCmd.Run(args) + if code != 0 { + t.Fatalf("bad: %d\n\n%s\n%s", code, ui.ErrorWriter, ui.OutputWriter) + } + expectedMsg = fmt.Sprintf("%s\n", selectedWorkspace) + if !strings.Contains(ui.OutputWriter.String(), expectedMsg) { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, ui.OutputWriter) + } + + current, _ = newCmd.Workspace() + if current != backend.DefaultStateName { + t.Fatal("current workspace should be 'default'") + } + + //// Delete Workspace + meta, ui, close = newMeta(mock) + defer close() + deleteCmd := &WorkspaceDeleteCommand{ + Meta: meta, + } + args = []string{newWorkspace} + code = deleteCmd.Run(args) + if code != 0 { + t.Fatalf("bad: %d\n\n%s\n%s", code, ui.ErrorWriter, ui.OutputWriter) + } + expectedMsg = fmt.Sprintf("Deleted workspace %q!\n", newWorkspace) + if !strings.Contains(ui.OutputWriter.String(), expectedMsg) { + t.Errorf("unexpected output, expected %q, but got:\n%s", expectedMsg, ui.OutputWriter) + } +} + func TestWorkspace_createAndChange(t *testing.T) { // Create a temporary working directory that is empty td := t.TempDir() @@ -114,7 +262,7 @@ func TestWorkspace_createAndList(t *testing.T) { t.Chdir(td) // make sure a vars file doesn't interfere - err := ioutil.WriteFile( + err := os.WriteFile( DefaultVarsFilename, []byte(`foo = "bar"`), 0644, @@ -162,7 +310,7 @@ func TestWorkspace_createAndShow(t *testing.T) { t.Chdir(td) // make sure a vars file doesn't interfere - err := ioutil.WriteFile( + err := os.WriteFile( DefaultVarsFilename, []byte(`foo = "bar"`), 0644, @@ -345,7 +493,7 @@ func TestWorkspace_delete(t *testing.T) { if err := os.MkdirAll(DefaultDataDir, 0755); err != nil { t.Fatal(err) } - if err := ioutil.WriteFile(filepath.Join(DefaultDataDir, local.DefaultWorkspaceFile), []byte("test"), 0644); err != nil { + if err := os.WriteFile(filepath.Join(DefaultDataDir, local.DefaultWorkspaceFile), []byte("test"), 0644); err != nil { t.Fatal(err) } diff --git a/internal/command/workspace_new.go b/internal/command/workspace_new.go index d36c76efef61..d217fd38e945 100644 --- a/internal/command/workspace_new.go +++ b/internal/command/workspace_new.go @@ -10,9 +10,12 @@ import ( "time" "github.com/hashicorp/cli" + "github.com/hashicorp/terraform/internal/backend/local" + backendPluggable "github.com/hashicorp/terraform/internal/backend/pluggable" "github.com/hashicorp/terraform/internal/command/arguments" "github.com/hashicorp/terraform/internal/command/clistate" "github.com/hashicorp/terraform/internal/command/views" + "github.com/hashicorp/terraform/internal/states" "github.com/hashicorp/terraform/internal/states/statefile" "github.com/hashicorp/terraform/internal/tfdiags" "github.com/posener/complete" @@ -93,12 +96,38 @@ func (c *WorkspaceNewCommand) Run(args []string) int { } } - _, sDiags := b.StateMgr(workspace) + // Create the new workspace + // + // In local, remote and remote-state backends obtaining a state manager + // creates an empty state file for the new workspace as a side-effect. + // + // The cloud backend also has logic in StateMgr for creating projects and + // workspaces if they don't already exist. + sMgr, sDiags := b.StateMgr(workspace) if sDiags.HasErrors() { c.Ui.Error(sDiags.Err().Error()) return 1 } + if l, ok := b.(*local.Local); ok { + if _, ok := l.Backend.(*backendPluggable.Pluggable); ok { + // Obtaining the state manager would have not created the state file as a side effect + // if a pluggable state store is in use. + // + // Instead, explicitly create the new workspace by saving an empty state file. + // We only do this when the backend in use is pluggable, to avoid impacting users + // of remote-state backends. + if err := sMgr.WriteState(states.NewState()); err != nil { + c.Ui.Error(err.Error()) + return 1 + } + if err := sMgr.PersistState(nil); err != nil { + c.Ui.Error(err.Error()) + return 1 + } + } + } + // now set the current workspace locally if err := c.SetWorkspace(workspace); err != nil { c.Ui.Error(fmt.Sprintf("Error selecting new workspace: %s", err)) diff --git a/internal/plugin6/grpc_provider.go b/internal/plugin6/grpc_provider.go index 0339cefeeea2..2f517f363af1 100644 --- a/internal/plugin6/grpc_provider.go +++ b/internal/plugin6/grpc_provider.go @@ -1841,6 +1841,7 @@ func (p *GRPCProvider) DeleteState(r providers.DeleteStateRequest) (resp provide protoReq := &proto6.DeleteState_Request{ TypeName: r.TypeName, + StateId: r.StateId, } schema := p.GetProviderSchema() diff --git a/internal/providers/testing/provider_mock.go b/internal/providers/testing/provider_mock.go index 8aa55051e3a2..e164593fc60c 100644 --- a/internal/providers/testing/provider_mock.go +++ b/internal/providers/testing/provider_mock.go @@ -361,6 +361,9 @@ func (p *MockProvider) WriteStateBytes(r providers.WriteStateBytesRequest) (resp // If we haven't already, record in the mock that // the matching workspace exists + if p.MockStates == nil { + p.MockStates = make(map[string]interface{}) + } p.MockStates[r.StateId] = true return p.WriteStateBytesResponse