Skip to content
Open
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
9 changes: 9 additions & 0 deletions internal/command/views/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
`
13 changes: 13 additions & 0 deletions internal/command/views/workspace_new.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}

Expand Down Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions internal/command/views/workspace_new_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
173 changes: 109 additions & 64 deletions internal/command/workspace_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
16 changes: 8 additions & 8 deletions internal/command/workspace_new.go
Original file line number Diff line number Diff line change
Expand Up @@ -137,25 +137,24 @@ 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
}

// load the new Backend state
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() {
Expand All @@ -169,32 +168,33 @@ 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()

stateFile, err := statefile.Read(f)
if err != nil {
diags = diags.Append(err)
view.Diagnostics(diags)
view.LogWorkspaceCreationFromStateFailure(workspace, diags)
return 1
}

// save the existing state in the new Backend.
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)

@SarahFrench SarahFrench Oct 2, 2026 •

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This output doesn't explicitly communicate 'we created a workspace and we used the -state flag as you expected', but I think that's fine. The more important thing is only reporting success alongside returning 0.

return 0
}

Expand Down
Loading