diff --git a/internal/command/views/workspace.go b/internal/command/views/workspace.go index 4dd45781f11c..bc505171947c 100644 --- a/internal/command/views/workspace.go +++ b/internal/command/views/workspace.go @@ -10,3 +10,12 @@ You're now on a new, empty workspace. Workspaces isolate their state, so if you run "terraform plan" Terraform will not see any existing state for this configuration. ` + +const envCreatedWithoutStatePopulation = ` +[reset][yellow][bold]Created and switched to workspace %q, but failed to initialize the state.[reset][yellow] + +You're now on a new, empty workspace. However, errors prevented Terraform from initializing the new workspace's state +using the provided '-state' flag. You will need to address these errors before the workspace's state can be properly +initialized. Once they're addressed, you can use "terraform workspace delete %s" to delete the newly created +workspace and then reattempt this operation. +` diff --git a/internal/command/views/workspace_new.go b/internal/command/views/workspace_new.go index 47a3aaa2e7b8..6232e11f7b84 100644 --- a/internal/command/views/workspace_new.go +++ b/internal/command/views/workspace_new.go @@ -16,6 +16,10 @@ type WorkspaceNew interface { // LogWorkspaceCreationSuccess is called when a new workspace has been successfully created LogWorkspaceCreationSuccess(workspaceName string, diags tfdiags.Diagnostics) + // LogWorkspaceCreationFromStateFailure is called when creating a new workspace from an existing state file fails. + // The user needs to be notified about the side effects from the failed operation. + LogWorkspaceCreationFromStateFailure(workspaceName string, diags tfdiags.Diagnostics) + Diagnostics(diags tfdiags.Diagnostics) } @@ -47,6 +51,15 @@ func (v *WorkspaceNewHuman) LogWorkspaceCreationSuccess(workspaceName string, di v.log(msg) } +func (v *WorkspaceNewHuman) LogWorkspaceCreationFromStateFailure(workspaceName string, diags tfdiags.Diagnostics) { + // Print diags above output + v.view.Diagnostics(diags) + + msg := fmt.Sprintf(envCreatedWithoutStatePopulation, workspaceName, workspaceName) + + v.log(msg) +} + // Diagnostics is used to display diagnostic messages, but should only be used when the command // is unsuccessful and returns early. func (v *WorkspaceNewHuman) Diagnostics(diags tfdiags.Diagnostics) { diff --git a/internal/command/views/workspace_new_test.go b/internal/command/views/workspace_new_test.go index 06c6e2a9a09f..2d1f238222cb 100644 --- a/internal/command/views/workspace_new_test.go +++ b/internal/command/views/workspace_new_test.go @@ -75,6 +75,56 @@ for this configuration.`, workspaceName) } } +func TestWorkspaceNewHuman_LogWorkspaceCreationFromStateFailure(t *testing.T) { + workspaceName := "my-workspace" + + testCases := map[string]struct { + workspace string + diags tfdiags.Diagnostics + wantStdoutSnippet string + wantStderr string + }{ + "error": { + workspaceName, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Example error", + "This is an example error message.", + ), + }, + fmt.Sprintf("Created and switched to workspace %q, but failed to initialize the state.", workspaceName), + "Error: Example error\n\nThis is an example error message.\n\n", + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + view.Configure(&arguments.View{NoColor: true}) + v := NewWorkspaceNew(arguments.ViewHuman, view) + + v.LogWorkspaceCreationFromStateFailure(tc.workspace, tc.diags) + + output := done(t) + + // Assert contents + // This must be done separately for stdout and stderr due to + // the interleaving of output caused in tests by (TestOutput).All() + gotStdout := strings.TrimSpace(output.Stdout()) + wantStdoutSnippet := strings.TrimSpace(tc.wantStdoutSnippet) + if !strings.Contains(gotStdout, wantStdoutSnippet) { + t.Fatalf("expected stdout to contain:\n%s\nbut got:\n%s", wantStdoutSnippet, gotStdout) + } + gotStderr := strings.TrimSpace(output.Stderr()) + wantStderr := strings.TrimSpace(tc.wantStderr) + if diff := cmp.Diff(wantStderr, gotStderr); diff != "" { + t.Fatalf("unexpected diff in human output:\n%s", diff) + } + }) + } +} + func TestWorkspaceNewHuman_Diagnostics(t *testing.T) { testCases := map[string]struct { diags tfdiags.Diagnostics diff --git a/internal/command/workspace_command_test.go b/internal/command/workspace_command_test.go index 98b2e55fcd8d..e658f455d440 100644 --- a/internal/command/workspace_command_test.go +++ b/internal/command/workspace_command_test.go @@ -567,80 +567,125 @@ func TestWorkspace_createInvalid(t *testing.T) { } func TestWorkspace_createWithState(t *testing.T) { - td := t.TempDir() - testCopyDir(t, testFixturePath("inmem-backend"), td) - t.Chdir(td) - defer inmem.Reset() + t.Run("success", func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("inmem-backend"), td) + t.Chdir(td) + defer inmem.Reset() - // init the backend - ui := testUiWrapped(t) - view, done := testView(t) - initCmd := &InitCommand{ - Meta: Meta{ - Ui: ui, - View: view, - WorkingDir: workdir.NewDir("."), - }, - } - if code := initCmd.Run([]string{}); code != 0 { - t.Fatalf("bad: \n%s", done(t).All()) - } - - originalState := states.BuildState(func(s *states.SyncState) { - s.SetResourceInstanceCurrent( - addrs.Resource{ - Mode: addrs.ManagedResourceMode, - Type: "test_instance", - Name: "foo", - }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), - &states.ResourceInstanceObjectSrc{ - AttrsJSON: []byte(`{"id":"bar"}`), - Status: states.ObjectReady, + // init the backend + ui := testUiWrapped(t) + view, done := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + Ui: ui, + View: view, + WorkingDir: workdir.NewDir("."), }, - addrs.AbsProviderConfig{ - Provider: addrs.NewDefaultProvider("test"), - Module: addrs.RootModule, + } + if code := initCmd.Run([]string{}); code != 0 { + t.Fatalf("bad: \n%s", done(t).All()) + } + + originalState := states.BuildState(func(s *states.SyncState) { + s.SetResourceInstanceCurrent( + addrs.Resource{ + Mode: addrs.ManagedResourceMode, + Type: "test_instance", + Name: "foo", + }.Instance(addrs.NoKey).Absolute(addrs.RootModuleInstance), + &states.ResourceInstanceObjectSrc{ + AttrsJSON: []byte(`{"id":"bar"}`), + Status: states.ObjectReady, + }, + addrs.AbsProviderConfig{ + Provider: addrs.NewDefaultProvider("test"), + Module: addrs.RootModule, + }, + ) + }) + + err := statemgr.NewFilesystem("test.tfstate").WriteState(originalState) + if err != nil { + t.Fatal(err) + } + + workspace := "test_workspace" + + args := []string{"-state", "test.tfstate", workspace} + view, done = testView(t) + newCmd := &WorkspaceNewCommand{ + Meta: Meta{ + View: view, + WorkingDir: workdir.NewDir("."), }, - ) - }) + } + if code := newCmd.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, done(t).All()) + } - err := statemgr.NewFilesystem("test.tfstate").WriteState(originalState) - if err != nil { - t.Fatal(err) - } + newPath := filepath.Join(local.DefaultWorkspaceDir, "test", DefaultStateFilename) + envState := statemgr.NewFilesystem(newPath) + err = envState.RefreshState() + if err != nil { + t.Fatal(err) + } - workspace := "test_workspace" + b := backend.TestBackendConfig(t, inmem.New(), nil) + sMgr, sDiags := b.StateMgr(workspace) + if sDiags.HasErrors() { + t.Fatal(sDiags) + } - args := []string{"-state", "test.tfstate", workspace} - view, done = testView(t) - newCmd := &WorkspaceNewCommand{ - Meta: Meta{ - View: view, - WorkingDir: workdir.NewDir("."), - }, - } - if code := newCmd.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, done(t).All()) - } + newState := sMgr.State() - newPath := filepath.Join(local.DefaultWorkspaceDir, "test", DefaultStateFilename) - envState := statemgr.NewFilesystem(newPath) - err = envState.RefreshState() - if err != nil { - t.Fatal(err) - } + if got, want := newState.String(), originalState.String(); got != want { + t.Fatalf("states not equal\ngot: %s\nwant: %s", got, want) + } + }) - b := backend.TestBackendConfig(t, inmem.New(), nil) - sMgr, sDiags := b.StateMgr(workspace) - if sDiags.HasErrors() { - t.Fatal(sDiags) - } + t.Run("failure due to invalid -state value", func(t *testing.T) { + td := t.TempDir() + testCopyDir(t, testFixturePath("inmem-backend"), td) + t.Chdir(td) + defer inmem.Reset() + + // init the backend + ui := testUiWrapped(t) + view, done := testView(t) + initCmd := &InitCommand{ + Meta: Meta{ + Ui: ui, + View: view, + WorkingDir: workdir.NewDir("."), + }, + } + if code := initCmd.Run([]string{}); code != 0 { + t.Fatalf("bad: \n%s", done(t).All()) + } - newState := sMgr.State() + workspace := "test_workspace" - if got, want := newState.String(), originalState.String(); got != want { - t.Fatalf("states not equal\ngot: %s\nwant: %s", got, want) - } + args := []string{ + "-state", "test.tfstate", // state doesn't exist + workspace, + } + view, done = testView(t) + newCmd := &WorkspaceNewCommand{ + Meta: Meta{ + View: view, + WorkingDir: workdir.NewDir("."), + }, + } + if code := newCmd.Run(args); code != 1 { + t.Fatalf("expected code 1 but got %s: %d\n\n%s", args, code, done(t).All()) + } + output := done(t) + expectedErrSnippet := fmt.Sprintf("Created and switched to workspace \"%s\", but failed to initialize the state.", workspace) + if !strings.Contains(output.All(), expectedErrSnippet) { + t.Fatalf("expected error message about missing state file, got: %s", output.All()) + } + }) } func TestWorkspace_delete(t *testing.T) { diff --git a/internal/command/workspace_new.go b/internal/command/workspace_new.go index e03d877f45de..f0a89e105b3d 100644 --- a/internal/command/workspace_new.go +++ b/internal/command/workspace_new.go @@ -137,10 +137,9 @@ func (c *WorkspaceNewCommand) Run(rawArgs []string) int { return 1 } - view.LogWorkspaceCreationSuccess(workspace, diags) - if args.StatePath == "" { // if we're not loading a state, then we're done + view.LogWorkspaceCreationSuccess(workspace, diags) return 0 } @@ -148,14 +147,14 @@ func (c *WorkspaceNewCommand) Run(rawArgs []string) int { stateMgr, sDiags := b.StateMgr(workspace) diags = diags.Append(sDiags) if sDiags.HasErrors() { - view.Diagnostics(diags) + view.LogWorkspaceCreationFromStateFailure(workspace, diags) return 1 } if args.Lock { stateLocker := clistate.NewLocker(args.LockTimeout, views.NewStateLocker(arguments.ViewHuman, c.View)) if diags := stateLocker.Lock(stateMgr, "workspace-new"); diags.HasErrors() { - view.Diagnostics(diags) + view.LogWorkspaceCreationFromStateFailure(workspace, diags) return 1 } defer func() { @@ -169,7 +168,7 @@ func (c *WorkspaceNewCommand) Run(rawArgs []string) int { f, err := os.Open(args.StatePath) if err != nil { diags = diags.Append(err) - view.Diagnostics(diags) + view.LogWorkspaceCreationFromStateFailure(workspace, diags) return 1 } defer f.Close() @@ -177,7 +176,7 @@ func (c *WorkspaceNewCommand) Run(rawArgs []string) int { stateFile, err := statefile.Read(f) if err != nil { diags = diags.Append(err) - view.Diagnostics(diags) + view.LogWorkspaceCreationFromStateFailure(workspace, diags) return 1 } @@ -185,16 +184,17 @@ func (c *WorkspaceNewCommand) Run(rawArgs []string) int { err = stateMgr.WriteState(stateFile.State) if err != nil { diags = diags.Append(err) - view.Diagnostics(diags) + view.LogWorkspaceCreationFromStateFailure(workspace, diags) return 1 } err = stateMgr.PersistState(nil) if err != nil { diags = diags.Append(err) - view.Diagnostics(diags) + view.LogWorkspaceCreationFromStateFailure(workspace, diags) return 1 } + view.LogWorkspaceCreationSuccess(workspace, diags) return 0 }