Skip to content

Commit

Permalink
New Terraform Workspace subcommand: selectornew
Browse files Browse the repository at this point in the history
This adds the new terraform workspace subcommand. This command is intended to make managing workspaces easier in CI environments where auto creating or selecting a workspace is useful.
  • Loading branch information
brittandeyoung committed Aug 12, 2022
1 parent 2aff678 commit 8abd500
Show file tree
Hide file tree
Showing 4 changed files with 377 additions and 1 deletion.
6 changes: 6 additions & 0 deletions commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,12 @@ func initCommands(
}, nil
},

"workspace selectornew": func() (cli.Command, error) {
return &command.WorkspaceSelectornewCommand{
Meta: meta,
}, nil
},

"workspace show": func() (cli.Command, error) {
return &command.WorkspaceShowCommand{
Meta: meta,
Expand Down
2 changes: 1 addition & 1 deletion internal/command/workspace_command.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ func (c *WorkspaceCommand) Help() string {
helpText := `
Usage: terraform [global options] workspace
new, list, show, select and delete Terraform workspaces.
new, list, show, select, selectornew and delete Terraform workspaces.
`
return strings.TrimSpace(helpText)
Expand Down
139 changes: 139 additions & 0 deletions internal/command/workspace_command_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -435,3 +435,142 @@ func TestWorkspace_deleteWithState(t *testing.T) {
t.Fatal("env 'test' still exists!")
}
}

func TestWorkspace_selectornewWithCreate(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
os.MkdirAll(td, 0755)
defer testChdir(t, td)()

selectornewCmd := &WorkspaceSelectornewCommand{}

current, _ := selectornewCmd.Workspace()
if current != backend.DefaultStateName {
t.Fatal("current workspace should be 'default'")
}

args := []string{"test"}
ui := new(cli.MockUi)
view, _ := testView(t)
selectornewCmd.Meta = Meta{Ui: ui, View: view}
if code := selectornewCmd.Run(args); code != 0 {
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter)
}

current, _ = selectornewCmd.Workspace()
if current != "test" {
t.Fatalf("current workspace should be 'test', got %q", current)
}

}

func TestWorkspace_selectornewWithSelect(t *testing.T) {
// Create a temporary working directory that is empty
td := t.TempDir()
os.MkdirAll(td, 0755)
defer testChdir(t, td)()

selectornewCmd := &WorkspaceSelectornewCommand{}

current, _ := selectornewCmd.Workspace()
if current != backend.DefaultStateName {
t.Fatal("current workspace should be 'default'")
}

// create the workspace directories
if err := os.MkdirAll(filepath.Join(local.DefaultWorkspaceDir, "test"), 0755); err != nil {
t.Fatal(err)
}

// create the workspace file
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 {
t.Fatal(err)
}

args := []string{"test"}
ui := new(cli.MockUi)
view, _ := testView(t)
selectornewCmd.Meta = Meta{Ui: ui, View: view}
if code := selectornewCmd.Run(args); code != 0 {
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter)
}

current, _ = selectornewCmd.Workspace()
if current != "test" {
t.Fatalf("current workspace should be 'test', got %q", current)
}

}

func TestWorkspace_selectornewWithState(t *testing.T) {
td := t.TempDir()
testCopyDir(t, testFixturePath("inmem-backend"), td)
defer testChdir(t, td)()
defer inmem.Reset()

// init the backend
ui := new(cli.MockUi)
view, _ := testView(t)
initCmd := &InitCommand{
Meta: Meta{Ui: ui, View: view},
}
if code := initCmd.Run([]string{}); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
}

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}
ui = new(cli.MockUi)
selectornewCmd := &WorkspaceSelectornewCommand{
Meta: Meta{Ui: ui, View: view},
}
if code := selectornewCmd.Run(args); code != 0 {
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter)
}

newPath := filepath.Join(local.DefaultWorkspaceDir, "test", DefaultStateFilename)
envState := statemgr.NewFilesystem(newPath)
err = envState.RefreshState()
if err != nil {
t.Fatal(err)
}

b := backend.TestBackendConfig(t, inmem.New(), nil)
sMgr, err := b.StateMgr(workspace)
if err != nil {
t.Fatal(err)
}

newState := sMgr.State()

if got, want := newState.String(), originalState.String(); got != want {
t.Fatalf("states not equal\ngot: %s\nwant: %s", got, want)
}
}
231 changes: 231 additions & 0 deletions internal/command/workspace_selectornew.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
package command

import (
"fmt"
"os"
"strings"
"time"

"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/statefile"
"github.com/hashicorp/terraform/internal/tfdiags"
"github.com/mitchellh/cli"
"github.com/posener/complete"
)

type WorkspaceSelectornewCommand struct {
Meta
LegacyName bool
}

func (c *WorkspaceSelectornewCommand) Run(args []string) int {
args = c.Meta.process(args)
envCommandShowWarning(c.Ui, c.LegacyName)

var stateLock bool
var stateLockTimeout time.Duration
var statePath string
cmdFlags := c.Meta.defaultFlagSet("workspace selectornew")
cmdFlags.BoolVar(&stateLock, "lock", true, "lock state")
cmdFlags.DurationVar(&stateLockTimeout, "lock-timeout", 0, "lock timeout")
cmdFlags.StringVar(&statePath, "state", "", "terraform state file")
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
return 1
}

args = cmdFlags.Args()
if len(args) != 1 {
c.Ui.Error("Expected a single argument: NAME.\n")
return cli.RunResultHelp
}

configPath, err := ModulePath(args[1:])
if err != nil {
c.Ui.Error(err.Error())
return 1
}

var diags tfdiags.Diagnostics

backendConfig, backendDiags := c.loadBackendConfig(configPath)
diags = diags.Append(backendDiags)
if diags.HasErrors() {
c.showDiagnostics(diags)
return 1
}

current, isOverridden := c.WorkspaceOverridden()
if isOverridden {
c.Ui.Error(envIsOverriddenSelectError)
return 1
}

// Load the backend
b, backendDiags := c.Backend(&BackendOpts{
Config: backendConfig,
})
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
c.showDiagnostics(diags)
return 1
}

if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to load backend: %s", err))
return 1
}

// This command will not write state
c.ignoreRemoteVersionConflict(b)

workspace := args[0]
if !validWorkspaceName(workspace) {
c.Ui.Error(fmt.Sprintf(envInvalidName, workspace))
return 1
}

states, err := b.Workspaces()
if err != nil {
c.Ui.Error(err.Error())
return 1
}

if workspace == current {
// already using this workspace
return 0
}

found := false
for _, s := range states {
if workspace == s {
found = true
break
}
}

if !found {
c.Colorize().Color(fmt.Sprintf(envDoesNotExist, workspace))

_, err = b.StateMgr(workspace)
if 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))
return 1
}

c.Ui.Output(c.Colorize().Color(fmt.Sprintf(
strings.TrimSpace(envCreated), workspace)))

if statePath == "" {
// if we're not loading a state, then we're done
return 0
}

// load the new Backend state
stateMgr, err := b.StateMgr(workspace)
if err != nil {
c.Ui.Error(err.Error())
return 1
}

if stateLock {
stateLocker := clistate.NewLocker(c.stateLockTimeout, views.NewStateLocker(arguments.ViewHuman, c.View))
if diags := stateLocker.Lock(stateMgr, "workspace-new"); diags.HasErrors() {
c.showDiagnostics(diags)
return 1
}
defer func() {
if diags := stateLocker.Unlock(); diags.HasErrors() {
c.showDiagnostics(diags)
}
}()
}

// read the existing state file
f, err := os.Open(statePath)
if err != nil {
c.Ui.Error(err.Error())
return 1
}

stateFile, err := statefile.Read(f)
if err != nil {
c.Ui.Error(err.Error())
return 1
}

// save the existing state in the new Backend.
err = stateMgr.WriteState(stateFile.State)
if err != nil {
c.Ui.Error(err.Error())
return 1
}
err = stateMgr.PersistState()
if err != nil {
c.Ui.Error(err.Error())
return 1
}

return 0
}

err = c.SetWorkspace(workspace)
if err != nil {
c.Ui.Error(err.Error())
return 1
}

c.Ui.Output(
c.Colorize().Color(
fmt.Sprintf(envChanged, workspace),
),
)

return 0
}

func (c *WorkspaceSelectornewCommand) AutocompleteArgs() complete.Predictor {
return completePredictSequence{
c.completePredictWorkspaceName(),
complete.PredictDirs(""),
}
}

func (c *WorkspaceSelectornewCommand) AutocompleteFlags() complete.Flags {
return complete.Flags{
"-state": complete.PredictFiles("*.tfstate"),
}
}

func (c *WorkspaceSelectornewCommand) Help() string {
helpText := `
Usage: terraform [global options] workspace selectornew [OPTIONS] NAME
Select a different Terraform workspace or create if it does not exist.
Options:
-lock=false Don't hold a state lock during the operation. This is
dangerous if others might concurrently run commands
against the same workspace.
-lock-timeout=0s Duration to retry a state lock.
-state=path Copy an existing state file into the new workspace.
`
return strings.TrimSpace(helpText)
}

func (c *WorkspaceSelectornewCommand) Synopsis() string {
return "Select or create a workspace"
}

0 comments on commit 8abd500

Please sign in to comment.