Add basic env commands

Used a single command with flags for now. We may refactor this out to
subcommands.
This commit is contained in:
James Bardin 2017-02-16 18:29:19 -05:00
parent e6eb71dde5
commit 31f033827f
3 changed files with 640 additions and 0 deletions

322
command/env_command.go Normal file
View File

@ -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.
`
)

312
command/env_command_test.go Normal file
View File

@ -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
}

View File

@ -69,6 +69,12 @@ func init() {
}, nil }, nil
}, },
"env": func() (cli.Command, error) {
return &command.EnvCommand{
Meta: meta,
}, nil
},
"fmt": func() (cli.Command, error) { "fmt": func() (cli.Command, error) {
return &command.FmtCommand{ return &command.FmtCommand{
Meta: meta, Meta: meta,