diff --git a/command/env_command.go b/command/env_command.go new file mode 100644 index 0000000000..9ad03b5eb8 --- /dev/null +++ b/command/env_command.go @@ -0,0 +1,322 @@ +package command + +import ( + "bytes" + "fmt" + "os" + "strings" + + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" +) + +// EnvCommand is a Command Implementation that manipulates local state +// environments. +type EnvCommand struct { + Meta + + newEnv string + delEnv string + statePath string + force bool + + // backend returns by Meta.Backend + b backend.Backend + // MultiState Backend + multi backend.MultiState +} + +func (c *EnvCommand) Run(args []string) int { + args = c.Meta.process(args, true) + + cmdFlags := c.Meta.flagSet("env") + cmdFlags.StringVar(&c.newEnv, "new", "", "create a new environment") + cmdFlags.StringVar(&c.delEnv, "delete", "", "delete an existing environment") + cmdFlags.StringVar(&c.statePath, "state", "", "terraform state file") + cmdFlags.BoolVar(&c.force, "force", false, "force removal of a non-empty environment") + cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + args = cmdFlags.Args() + if len(args) > 1 { + c.Ui.Error("0 or 1 arguments expected.\n") + return cli.RunResultHelp + } + + // Load the backend + b, err := c.Backend(nil) + if err != nil { + c.Ui.Error(fmt.Sprintf("Failed to load backend: %s", err)) + return 1 + } + c.b = b + + multi, ok := b.(backend.MultiState) + if !ok { + c.Ui.Error(envNotSupported) + return 1 + } + c.multi = multi + + if c.newEnv != "" { + return c.createEnv() + } + + if c.delEnv != "" { + return c.deleteEnv() + } + + if len(args) == 1 { + return c.changeEnv(args[0]) + } + + return c.listEnvs() +} + +func (c *EnvCommand) createEnv() int { + states, _, err := c.multi.States() + for _, s := range states { + if c.newEnv == s { + c.Ui.Error(fmt.Sprintf(envExists, c.newEnv)) + return 1 + } + } + + err = c.multi.ChangeState(c.newEnv) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + c.Ui.Output( + c.Colorize().Color( + fmt.Sprintf(envCreated, c.newEnv), + ), + ) + + if c.statePath == "" { + // if we're not loading a state, then we're done + return 0 + } + + // load the new state + sMgr, err := c.b.State() + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + // load the existing state + stateFile, err := os.Open(c.statePath) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + s, err := terraform.ReadState(stateFile) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + err = sMgr.WriteState(s) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + return 0 +} + +func (c *EnvCommand) deleteEnv() int { + states, current, err := c.multi.States() + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + exists := false + for _, s := range states { + if c.delEnv == s { + exists = true + break + } + } + + if !exists { + c.Ui.Error(fmt.Sprintf(envDoesNotExist, c.delEnv)) + return 1 + } + + // In order to check if the state being deleted is empty, we need to change + // to that state and load it. + if current != c.delEnv { + if err := c.multi.ChangeState(c.delEnv); err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + // always try to change back after + defer func() { + if err := c.multi.ChangeState(current); err != nil { + c.Ui.Error(err.Error()) + } + }() + } + + // we need the actual state to see if it's empty + sMgr, err := c.b.State() + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + if err := sMgr.RefreshState(); err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + empty := sMgr.State().Empty() + + if !empty && !c.force { + c.Ui.Error(fmt.Sprintf(envNotEmpty, c.delEnv)) + return 1 + } + + err = c.multi.DeleteState(c.delEnv) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + c.Ui.Output( + c.Colorize().Color( + fmt.Sprintf(envDeleted, c.delEnv), + ), + ) + + if !empty { + c.Ui.Output( + c.Colorize().Color( + fmt.Sprintf(envWarnNotEmpty, c.delEnv), + ), + ) + } + + return 0 +} + +func (c *EnvCommand) changeEnv(name string) int { + states, current, err := c.multi.States() + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + if current == name { + return 0 + } + + found := false + for _, s := range states { + if name == s { + found = true + break + } + } + + if !found { + c.Ui.Error(fmt.Sprintf(envDoesNotExist, name)) + return 1 + } + + err = c.multi.ChangeState(name) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + c.Ui.Output( + c.Colorize().Color( + fmt.Sprintf(envChanged, name), + ), + ) + + return 0 +} + +func (c *EnvCommand) listEnvs() int { + states, current, err := c.multi.States() + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + var out bytes.Buffer + for _, s := range states { + if s == current { + out.WriteString("* ") + } else { + out.WriteString(" ") + } + out.WriteString(s + "\n") + } + + c.Ui.Output(out.String()) + return 0 +} + +func (c *EnvCommand) Help() string { + helpText := ` +Usage: terraform env [options] [NAME] + + Create, change and delete Terraform environments. + + By default env will list all configured environments. If NAME is provided, + env will change into to that named environment. + + +Options: + + -new=name Create a new environment. + -delete=name Delete an existing environment, + + -state=path Used with -new to copy a state file into the new environment. + -force Used with -delete to remove a non-empty environment. +` + return strings.TrimSpace(helpText) +} + +func (c *EnvCommand) Synopsis() string { + return "Environment management" +} + +const ( + envNotSupported = `Backend does not support environments` + + envExists = `Environment %q already exists` + + envDoesNotExist = `Environment %q doesn't exist! +You can create this environment with the "-new" option.` + + envChanged = `[reset][green]Switched to environment %q!` + + envCreated = `[reset][green]Created environment %q!` + + envDeleted = `[reset][green]Deleted environment %q!` + + envNotEmpty = `Environment %[1]q is not empty! +Deleting %[1]q can result in dangling resources: resources that +exist but are no longer manageable by Terraform. Please destroy +these resources first. If you want to delete this environment +anyways and risk dangling resources, use the '-force' flag. +` + + envWarnNotEmpty = `[reset][yellow]WARNING: %q was non-empty. +The resources managed by the deleted environment may still exist, +but are no longer manageable by Terraform since the state has +been deleted. +` +) diff --git a/command/env_command_test.go b/command/env_command_test.go new file mode 100644 index 0000000000..8257932ad0 --- /dev/null +++ b/command/env_command_test.go @@ -0,0 +1,312 @@ +package command + +import ( + "io/ioutil" + "os" + "path/filepath" + "sort" + "strings" + "testing" + + "github.com/hashicorp/terraform/backend" + "github.com/hashicorp/terraform/backend/local" + "github.com/hashicorp/terraform/state" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" +) + +func TestEnv_createAndChange(t *testing.T) { + // Create a temporary working directory that is empty + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + c := &EnvCommand{} + + current, err := currentEnv() + if err != nil { + t.Fatal(err) + } + if current != backend.DefaultStateName { + t.Fatal("current env should be 'default'") + } + + args := []string{"-new", "test"} + ui := new(cli.MockUi) + c.Meta = Meta{Ui: ui} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + + current, err = currentEnv() + if err != nil { + t.Fatal(err) + } + if current != "test" { + t.Fatal("current env should be 'test'") + } + + args = []string{backend.DefaultStateName} + ui = new(cli.MockUi) + c.Meta = Meta{Ui: ui} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + + current, err = currentEnv() + if err != nil { + t.Fatal(err) + } + + if current != backend.DefaultStateName { + t.Fatal("current env should be 'default'") + } + +} + +// Create some environments and test the list output. +// This also ensures we switch to the correct env after each call +func TestEnv_createAndList(t *testing.T) { + // Create a temporary working directory that is empty + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + c := &EnvCommand{} + + envs := []string{"test_a", "test_b", "test_c"} + + // create multiple envs + for _, env := range envs { + args := []string{"-new", env} + ui := new(cli.MockUi) + c.Meta = Meta{Ui: ui} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + } + + // now check the listing + expected := "default\n test_a\n test_b\n* test_c" + + ui := new(cli.MockUi) + c.Meta = Meta{Ui: ui} + + if code := c.Run(nil); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + + actual := strings.TrimSpace(ui.OutputWriter.String()) + if actual != expected { + t.Fatalf("\nexpcted: %q\nactual: %q", expected, actual) + } +} + +func TestEnv_createWithState(t *testing.T) { + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + // create a non-empty state + originalState := &terraform.State{ + Modules: []*terraform.ModuleState{ + &terraform.ModuleState{ + Path: []string{"root"}, + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + Type: "test_instance", + Primary: &terraform.InstanceState{ + ID: "bar", + }, + }, + }, + }, + }, + } + + err := (&state.LocalState{Path: "test.tfstate"}).WriteState(originalState) + if err != nil { + t.Fatal(err) + } + + args := []string{"-new", "test", "-state", "test.tfstate"} + ui := new(cli.MockUi) + c := &EnvCommand{ + Meta: Meta{Ui: ui}, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter) + } + + newPath := filepath.Join(local.DefaultEnvDir, "test", DefaultStateFilename) + envState := state.LocalState{Path: newPath} + err = envState.RefreshState() + if err != nil { + t.Fatal(err) + } + + newState := envState.State() + if !originalState.Equal(newState) { + t.Fatalf("states not equal\norig: %s\nnew: %s", originalState, newState) + } +} + +func TestEnv_delete(t *testing.T) { + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + // create the env directories + if err := os.MkdirAll(filepath.Join(local.DefaultEnvDir, "test"), 0755); err != nil { + t.Fatal(err) + } + + // create the environment file + if err := os.MkdirAll(DefaultDataDir, 0755); err != nil { + t.Fatal(err) + } + if err := ioutil.WriteFile(filepath.Join(DefaultDataDir, local.DefaultEnvFile), []byte("test"), 0644); err != nil { + t.Fatal(err) + } + + current, err := currentEnv() + if err != nil { + t.Fatal(err) + } + + if current != "test" { + t.Fatal("wrong env:", current) + } + + ui := new(cli.MockUi) + c := &EnvCommand{ + Meta: Meta{Ui: ui}, + } + args := []string{"-delete", "test"} + if code := c.Run(args); code != 0 { + t.Fatalf("failure: %s", ui.ErrorWriter) + } + + current, err = currentEnv() + if err != nil { + t.Fatal(err) + } + + if current != backend.DefaultStateName { + t.Fatalf("wrong env: %q", current) + } +} +func TestEnv_deleteWithState(t *testing.T) { + td := tempDir(t) + os.MkdirAll(td, 0755) + defer os.RemoveAll(td) + defer testChdir(t, td)() + + // create the env directories + if err := os.MkdirAll(filepath.Join(local.DefaultEnvDir, "test"), 0755); err != nil { + t.Fatal(err) + } + + // create a non-empty state + originalState := &terraform.State{ + Modules: []*terraform.ModuleState{ + &terraform.ModuleState{ + Path: []string{"root"}, + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + Type: "test_instance", + Primary: &terraform.InstanceState{ + ID: "bar", + }, + }, + }, + }, + }, + } + + envStatePath := filepath.Join(local.DefaultEnvDir, "test", DefaultStateFilename) + err := (&state.LocalState{Path: envStatePath}).WriteState(originalState) + if err != nil { + t.Fatal(err) + } + + ui := new(cli.MockUi) + c := &EnvCommand{ + Meta: Meta{Ui: ui}, + } + args := []string{"-delete", "test"} + if code := c.Run(args); code == 0 { + t.Fatalf("expected failure without -force.\noutput: %s", ui.OutputWriter) + } + + ui = new(cli.MockUi) + c.Meta.Ui = ui + + args = []string{"-delete", "test", "-force"} + if code := c.Run(args); code != 0 { + t.Fatalf("failure: %s", ui.ErrorWriter) + } + + if _, err := os.Stat(filepath.Join(local.DefaultEnvDir, "test")); !os.IsNotExist(err) { + t.Fatal("env 'test' still exists!") + } +} + +func currentEnv() (string, error) { + contents, err := ioutil.ReadFile(filepath.Join(DefaultDataDir, local.DefaultEnvFile)) + if os.IsNotExist(err) { + return backend.DefaultStateName, nil + } + if err != nil { + return "", err + } + + current := strings.TrimSpace(string(contents)) + if current == "" { + current = backend.DefaultStateName + } + + return current, nil +} + +func envStatePath() (string, error) { + currentEnv, err := currentEnv() + if err != nil { + return "", err + } + + if currentEnv == backend.DefaultStateName { + return DefaultStateFilename, nil + } + + return filepath.Join(local.DefaultEnvDir, currentEnv, DefaultStateFilename), nil +} + +func listEnvs() ([]string, error) { + entries, err := ioutil.ReadDir(local.DefaultEnvDir) + // no error if there's no envs configured + if os.IsNotExist(err) { + return []string{backend.DefaultStateName}, nil + } + if err != nil { + return nil, err + } + + var envs []string + for _, entry := range entries { + if entry.IsDir() { + envs = append(envs, filepath.Base(entry.Name())) + } + } + + sort.Strings(envs) + + // always start with "default" + envs = append([]string{backend.DefaultStateName}, envs...) + + return envs, nil +} diff --git a/commands.go b/commands.go index 20e2ff8921..18ccd6d526 100644 --- a/commands.go +++ b/commands.go @@ -69,6 +69,12 @@ func init() { }, nil }, + "env": func() (cli.Command, error) { + return &command.EnvCommand{ + Meta: meta, + }, nil + }, + "fmt": func() (cli.Command, error) { return &command.FmtCommand{ Meta: meta,