diff --git a/.gitignore b/.gitignore index f5a7400d3..03a5621e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ *.dll *.exe example.tf +terraform.tfplan terraform.tfstate bin/ vendor/ diff --git a/builtin/providers/aws/resource_aws_route_table.go b/builtin/providers/aws/resource_aws_route_table.go index 12a0fffe1..7ba631903 100644 --- a/builtin/providers/aws/resource_aws_route_table.go +++ b/builtin/providers/aws/resource_aws_route_table.go @@ -110,12 +110,18 @@ func resource_aws_route_table_update( break } - // Append to the routes what we've done so far - resultRoutes = append(resultRoutes, map[string]string{ - "cidr_block": op.Route.DestinationCidrBlock, - "gateway_id": op.Route.GatewayId, - "instance_id": op.Route.InstanceId, - }) + // If we didn't delete the route, append it to the list of routes + // we have. + if op.Op != routeTableOpDelete { + resultMap := map[string]string{"cidr_block": op.Route.DestinationCidrBlock} + if op.Route.GatewayId != "" { + resultMap["gateway_id"] = op.Route.GatewayId + } else if op.Route.InstanceId != "" { + resultMap["instance_id"] = op.Route.InstanceId + } + + resultRoutes = append(resultRoutes, resultMap) + } } // Update our state with the settings diff --git a/command/apply.go b/command/apply.go index 448258dec..0276d9847 100644 --- a/command/apply.go +++ b/command/apply.go @@ -1,57 +1,70 @@ package command import ( + "bytes" "flag" "fmt" "os" + "sort" "strings" "github.com/hashicorp/terraform/terraform" - "github.com/mitchellh/cli" ) // ApplyCommand is a Command implementation that applies a Terraform // configuration and actually builds or changes infrastructure. type ApplyCommand struct { - ShutdownCh <-chan struct{} - ContextOpts *terraform.ContextOpts - Ui cli.Ui + Meta + + ShutdownCh <-chan struct{} } func (c *ApplyCommand) Run(args []string) int { var init bool - var stateOutPath string + var statePath, stateOutPath string + + args = c.Meta.process(args) cmdFlags := flag.NewFlagSet("apply", flag.ContinueOnError) cmdFlags.BoolVar(&init, "init", false, "init") - cmdFlags.StringVar(&stateOutPath, "out", "", "path") + cmdFlags.StringVar(&statePath, "state", DefaultStateFilename, "path") + cmdFlags.StringVar(&stateOutPath, "state-out", "", "path") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } if err := cmdFlags.Parse(args); err != nil { return 1 } + var configPath string args = cmdFlags.Args() - if len(args) != 2 { - c.Ui.Error("The apply command expects two arguments.\n") + if len(args) > 1 { + c.Ui.Error("The apply command expacts at most one argument.") cmdFlags.Usage() return 1 + } else if len(args) == 1 { + configPath = args[0] + } else { + var err error + configPath, err = os.Getwd() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error getting pwd: %s", err)) + } } - statePath := args[0] - configPath := args[1] - + // If we don't specify an output path, default to out normal state + // path. if stateOutPath == "" { stateOutPath = statePath } + // The state path to use to generate a plan. If we're initializing + // a new infrastructure, then we don't use a state path. planStatePath := statePath if init { planStatePath = "" } - // Initialize Terraform right away - c.ContextOpts.Hooks = append(c.ContextOpts.Hooks, &UiHook{Ui: c.Ui}) - ctx, err := ContextArg(configPath, planStatePath, c.ContextOpts) + // Build the context based on the arguments given + ctx, err := c.Context(configPath, planStatePath) if err != nil { c.Ui.Error(err.Error()) return 1 @@ -60,6 +73,7 @@ func (c *ApplyCommand) Run(args []string) int { return 1 } + // Start the apply in a goroutine so that we can be interrupted. var state *terraform.State var applyErr error doneCh := make(chan struct{}) @@ -68,6 +82,8 @@ func (c *ApplyCommand) Run(args []string) int { state, applyErr = ctx.Apply() }() + // Wait for the apply to finish or for us to be interrupted so + // we can handle it properly. err = nil select { case <-c.ShutdownCh: @@ -106,25 +122,71 @@ func (c *ApplyCommand) Run(args []string) int { return 1 } - c.Ui.Output(strings.TrimSpace(state.String())) + c.Ui.Output(c.Colorize().Color(fmt.Sprintf( + "[reset][bold][green]\n"+ + "Apply succeeded! Infrastructure created and/or updated.\n"+ + "The state of your infrastructure has been saved to the path\n"+ + "below. This state is required to modify and destroy your\n"+ + "infrastructure, so keep it safe. To inspect the complete state\n"+ + "use the `terraform show` command.\n\n"+ + "State path: %s", + stateOutPath))) + + // If we have outputs, then output those at the end. + if len(state.Outputs) > 0 { + outputBuf := new(bytes.Buffer) + outputBuf.WriteString("[reset][bold][green]\nOutputs:\n\n") + + // Output the outputs in alphabetical order + keyLen := 0 + keys := make([]string, 0, len(state.Outputs)) + for key, _ := range state.Outputs { + keys = append(keys, key) + if len(key) > keyLen { + keyLen = len(key) + } + } + sort.Strings(keys) + + for _, k := range keys { + v := state.Outputs[k] + + outputBuf.WriteString(fmt.Sprintf( + " %s%s = %s\n", + k, + strings.Repeat(" ", keyLen-len(k)), + v)) + } + + c.Ui.Output(c.Colorize().Color( + strings.TrimSpace(outputBuf.String()))) + } return 0 } func (c *ApplyCommand) Help() string { helpText := ` -Usage: terraform apply [options] STATE PATH +Usage: terraform apply [options] [dir] - Builds or changes infrastructure according to the Terraform configuration - file. + Builds or changes infrastructure according to Terraform configuration + files . Options: - -init If specified, it is okay to build brand new - infrastructure (with no state file specified). + -init If specified, new infrastructure can be built (no + previous state). This is just a safety switch + to prevent accidentally spinning up a new + infrastructure. - -out=file.tfstate Path to save the new state. If not specified, the - state path argument will be used. + -no-color If specified, output won't contain any color. + + -state=path Path to read and save state (unless state-out + is specified). Defaults to "terraform.tfstate". + + -state-out=path Path to write state to that is different than + "-state". This can be used to preserve the old + state. ` return strings.TrimSpace(helpText) diff --git a/command/apply_test.go b/command/apply_test.go index 750e533f5..48a1a1c70 100644 --- a/command/apply_test.go +++ b/command/apply_test.go @@ -2,7 +2,9 @@ package command import ( "fmt" + "io/ioutil" "os" + "path/filepath" "reflect" "sync" "testing" @@ -19,13 +21,15 @@ func TestApply(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &ApplyCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } args := []string{ "-init", - statePath, + "-state", statePath, testFixturePath("apply"), } if code := c.Run(args); code != 0 { @@ -55,13 +59,15 @@ func TestApply_configInvalid(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &ApplyCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } args := []string{ "-init", - testTempFile(t), + "-state", testTempFile(t), testFixturePath("apply-config-invalid"), } if code := c.Run(args); code != 1 { @@ -69,14 +75,69 @@ func TestApply_configInvalid(t *testing.T) { } } +func TestApply_defaultState(t *testing.T) { + td, err := ioutil.TempDir("", "tf") + if err != nil { + t.Fatalf("err: %s", err) + } + statePath := filepath.Join(td, DefaultStateFilename) + + // Change to the temporary directory + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(filepath.Dir(statePath)); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + p := testProvider() + ui := new(cli.MockUi) + c := &ApplyCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + } + + args := []string{ + "-init", + testFixturePath("apply"), + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + if _, err := os.Stat(statePath); err != nil { + t.Fatalf("err: %s", err) + } + + f, err := os.Open(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + defer f.Close() + + state, err := terraform.ReadState(f) + if err != nil { + t.Fatalf("err: %s", err) + } + if state == nil { + t.Fatal("state should not be nil") + } +} + func TestApply_error(t *testing.T) { statePath := testTempFile(t) p := testProvider() ui := new(cli.MockUi) c := &ApplyCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } var lock sync.Mutex @@ -108,7 +169,7 @@ func TestApply_error(t *testing.T) { args := []string{ "-init", - statePath, + "-state", statePath, testFixturePath("apply-error"), } if code := c.Run(args); code != 1 { @@ -137,6 +198,54 @@ func TestApply_error(t *testing.T) { } } +func TestApply_noArgs(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(testFixturePath("plan")); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + statePath := testTempFile(t) + + p := testProvider() + ui := new(cli.MockUi) + c := &ApplyCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + } + + args := []string{ + "-init", + "-state", statePath, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + if _, err := os.Stat(statePath); err != nil { + t.Fatalf("err: %s", err) + } + + f, err := os.Open(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + defer f.Close() + + state, err := terraform.ReadState(f) + if err != nil { + t.Fatalf("err: %s", err) + } + if state == nil { + t.Fatal("state should not be nil") + } +} + func TestApply_plan(t *testing.T) { planPath := testPlanFile(t, &terraform.Plan{ Config: new(config.Config), @@ -146,12 +255,14 @@ func TestApply_plan(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &ApplyCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } args := []string{ - statePath, + "-state", statePath, planPath, } if code := c.Run(args); code != 0 { @@ -188,9 +299,12 @@ func TestApply_shutdown(t *testing.T) { shutdownCh := make(chan struct{}) ui := new(cli.MockUi) c := &ApplyCommand{ - ContextOpts: testCtxConfig(p), - ShutdownCh: shutdownCh, - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + + ShutdownCh: shutdownCh, } p.DiffFn = func( @@ -235,7 +349,7 @@ func TestApply_shutdown(t *testing.T) { args := []string{ "-init", - statePath, + "-state", statePath, testFixturePath("apply-shutdown"), } if code := c.Run(args); code != 0 { @@ -288,13 +402,15 @@ func TestApply_state(t *testing.T) { ui := new(cli.MockUi) c := &ApplyCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } // Run the apply command pointing to our existing state args := []string{ - statePath, + "-state", statePath, testFixturePath("apply"), } if code := c.Run(args); code != 0 { @@ -335,8 +451,10 @@ func TestApply_stateNoExist(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &ApplyCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } args := []string{ diff --git a/command/cli_ui.go b/command/cli_ui.go new file mode 100644 index 000000000..b9fc23556 --- /dev/null +++ b/command/cli_ui.go @@ -0,0 +1,42 @@ +package command + +import ( + "fmt" + + "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" +) + +// ColoredUi is a Ui implementation that colors its output according +// to the given color schemes for the given type of output. +type ColorizeUi struct { + Colorize *colorstring.Colorize + OutputColor string + InfoColor string + ErrorColor string + Ui cli.Ui +} + +func (u *ColorizeUi) Ask(query string) (string, error) { + return u.Ui.Ask(u.colorize(query, u.OutputColor)) +} + +func (u *ColorizeUi) Output(message string) { + u.Ui.Output(u.colorize(message, u.OutputColor)) +} + +func (u *ColorizeUi) Info(message string) { + u.Ui.Info(u.colorize(message, u.InfoColor)) +} + +func (u *ColorizeUi) Error(message string) { + u.Ui.Error(u.colorize(message, u.ErrorColor)) +} + +func (u *ColorizeUi) colorize(message string, color string) string { + if color == "" { + return message + } + + return u.Colorize.Color(fmt.Sprintf("%s%s[reset]", color, message)) +} diff --git a/command/cli_ui_test.go b/command/cli_ui_test.go new file mode 100644 index 000000000..ac2b7d7ea --- /dev/null +++ b/command/cli_ui_test.go @@ -0,0 +1,11 @@ +package command + +import ( + "testing" + + "github.com/mitchellh/cli" +) + +func TestColorizeUi_impl(t *testing.T) { + var _ cli.Ui = new(ColorizeUi) +} diff --git a/command/command.go b/command/command.go index fd316f4b1..9d27321d5 100644 --- a/command/command.go +++ b/command/command.go @@ -2,74 +2,13 @@ package command import ( "fmt" - "os" - "github.com/hashicorp/terraform/config" "github.com/hashicorp/terraform/terraform" "github.com/mitchellh/cli" ) -func ContextArg( - path string, - statePath string, - opts *terraform.ContextOpts) (*terraform.Context, error) { - // First try to just read the plan directly from the path given. - f, err := os.Open(path) - if err == nil { - plan, err := terraform.ReadPlan(f) - f.Close() - if err == nil { - return plan.Context(opts), nil - } - } - - if statePath != "" { - if _, err := os.Stat(statePath); err != nil { - return nil, fmt.Errorf( - "There was an error reading the state file. The path\n"+ - "and error are shown below. If you're trying to build a\n"+ - "brand new infrastructure, explicitly pass the '-init'\n"+ - "flag to Terraform to tell it it is okay to build new\n"+ - "state.\n\n"+ - "Path: %s\n"+ - "Error: %s", - statePath, - err) - } - } - - // Load up the state - var state *terraform.State - if statePath != "" { - f, err := os.Open(statePath) - if err == nil { - state, err = terraform.ReadState(f) - f.Close() - } - - if err != nil { - return nil, fmt.Errorf("Error loading state: %s", err) - } - } - - config, err := config.Load(path) - if err != nil { - return nil, fmt.Errorf("Error loading config: %s", err) - } - if err := config.Validate(); err != nil { - return nil, fmt.Errorf("Error validating config: %s", err) - } - - opts.Config = config - opts.State = state - ctx := terraform.NewContext(opts) - - if _, err := ctx.Plan(nil); err != nil { - return nil, fmt.Errorf("Error running plan: %s", err) - } - - return ctx, nil -} +// DefaultStateFilename is the default filename used for the state file. +const DefaultStateFilename = "terraform.tfstate" func validateContext(ctx *terraform.Context, ui cli.Ui) bool { if ws, es := ctx.Validate(); len(ws) > 0 || len(es) > 0 { diff --git a/command/command_test.go b/command/command_test.go index 2f3207aa7..0f231ba90 100644 --- a/command/command_test.go +++ b/command/command_test.go @@ -10,10 +10,20 @@ import ( ) // This is the directory where our test fixtures are. -const fixtureDir = "./test-fixtures" +var fixtureDir = "./test-fixtures" + +func init() { + // Expand the fixture dir on init because we change the working + // directory in some tests. + var err error + fixtureDir, err = filepath.Abs(fixtureDir) + if err != nil { + panic(err) + } +} func testFixturePath(name string) string { - return filepath.Join(fixtureDir, name, "main.tf") + return filepath.Join(fixtureDir, name) } func testCtxConfig(p terraform.ResourceProvider) *terraform.ContextOpts { diff --git a/command/format_plan.go b/command/format_plan.go new file mode 100644 index 000000000..6ebf2ecc8 --- /dev/null +++ b/command/format_plan.go @@ -0,0 +1,101 @@ +package command + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/colorstring" +) + +// FormatPlan takes a plan and returns a +func FormatPlan(p *terraform.Plan, c *colorstring.Colorize) string { + if p.Diff == nil || p.Diff.Empty() { + return "This plan does nothing." + } + + if c == nil { + c = &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Reset: false, + } + } + + buf := new(bytes.Buffer) + + // We want to output the resources in sorted order to make things + // easier to scan through, so get all the resource names and sort them. + names := make([]string, 0, len(p.Diff.Resources)) + for name, _ := range p.Diff.Resources { + names = append(names, name) + } + sort.Strings(names) + + // Go through each sorted name and start building the output + for _, name := range names { + rdiff := p.Diff.Resources[name] + + // Determine the color for the text (green for adding, yellow + // for change, red for delete), and symbol, and output the + // resource header. + color := "yellow" + symbol := "~" + if rdiff.RequiresNew() { + color = "green" + symbol = "+" + } else if rdiff.Destroy { + color = "red" + symbol = "-" + } + buf.WriteString(c.Color(fmt.Sprintf( + "[%s]%s %s\n", + color, symbol, name))) + + // Get all the attributes that are changing, and sort them. Also + // determine the longest key so that we can align them all. + keyLen := 0 + keys := make([]string, 0, len(rdiff.Attributes)) + for key, _ := range rdiff.Attributes { + // Skip the ID since we do that specially + if key == "id" { + continue + } + + keys = append(keys, key) + if len(key) > keyLen { + keyLen = len(key) + } + } + sort.Strings(keys) + + // Go through and output each attribute + for _, attrK := range keys { + attrDiff := rdiff.Attributes[attrK] + + v := attrDiff.New + if attrDiff.NewComputed { + v = "" + } + + newResource := "" + if attrDiff.RequiresNew && rdiff.Destroy { + newResource = " (forces new resource)" + } + + buf.WriteString(fmt.Sprintf( + " %s:%s %#v => %#v%s\n", + attrK, + strings.Repeat(" ", keyLen-len(attrK)), + attrDiff.Old, + v, + newResource)) + } + + // Write the reset color so we don't overload the user's terminal + buf.WriteString(c.Color("[reset]\n")) + } + + return strings.TrimSpace(buf.String()) +} diff --git a/command/format_state.go b/command/format_state.go new file mode 100644 index 000000000..4a67ef7ee --- /dev/null +++ b/command/format_state.go @@ -0,0 +1,82 @@ +package command + +import ( + "bytes" + "fmt" + "sort" + "strings" + + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/colorstring" +) + +// FormatState takes a state and returns a string +func FormatState(s *terraform.State, c *colorstring.Colorize) string { + if c == nil { + panic("colorize not given") + } + + if len(s.Resources) == 0 { + return "The state file is empty. No resources are represented." + } + + buf := new(bytes.Buffer) + buf.WriteString("[reset]") + + // First get the names of all the resources so we can show them + // in alphabetical order. + names := make([]string, 0, len(s.Resources)) + for name, _ := range s.Resources { + names = append(names, name) + } + sort.Strings(names) + + // Go through each resource and begin building up the output. + for _, k := range names { + rs := s.Resources[k] + id := rs.ID + if id == "" { + id = "" + } + + buf.WriteString(fmt.Sprintf("%s:\n", k)) + buf.WriteString(fmt.Sprintf(" id = %s\n", id)) + + // Sort the attributes + attrKeys := make([]string, 0, len(rs.Attributes)) + for ak, _ := range rs.Attributes { + // Skip the id attribute since we just show the id directly + if ak == "id" { + continue + } + + attrKeys = append(attrKeys, ak) + } + sort.Strings(attrKeys) + + // Output each attribute + for _, ak := range attrKeys { + av := rs.Attributes[ak] + buf.WriteString(fmt.Sprintf(" %s = %s\n", ak, av)) + } + } + + if len(s.Outputs) > 0 { + buf.WriteString("\nOutputs:\n\n") + + // Sort the outputs + ks := make([]string, 0, len(s.Outputs)) + for k, _ := range s.Outputs { + ks = append(ks, k) + } + sort.Strings(ks) + + // Output each output k/v pair + for _, k := range ks { + v := s.Outputs[k] + buf.WriteString(fmt.Sprintf("%s = %s\n", k, v)) + } + } + + return c.Color(strings.TrimSpace(buf.String())) +} diff --git a/command/graph.go b/command/graph.go index 0d36166cd..f0c4bcf3f 100644 --- a/command/graph.go +++ b/command/graph.go @@ -1,58 +1,66 @@ package command import ( + "bytes" "flag" "fmt" "os" "strings" - "github.com/hashicorp/terraform/config" "github.com/hashicorp/terraform/digraph" - "github.com/hashicorp/terraform/terraform" - "github.com/mitchellh/cli" ) // GraphCommand is a Command implementation that takes a Terraform // configuration and outputs the dependency tree in graphical form. type GraphCommand struct { - ContextOpts *terraform.ContextOpts - Ui cli.Ui + Meta } func (c *GraphCommand) Run(args []string) int { + args = c.Meta.process(args) + cmdFlags := flag.NewFlagSet("graph", flag.ContinueOnError) cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } if err := cmdFlags.Parse(args); err != nil { return 1 } + var path string args = cmdFlags.Args() - if len(args) != 1 { + if len(args) > 1 { c.Ui.Error("The graph command expects one argument.\n") cmdFlags.Usage() return 1 + } else if len(args) == 1 { + path = args[0] + } else { + var err error + path, err = os.Getwd() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error getting pwd: %s", err)) + } } - conf, err := config.Load(args[0]) + ctx, err := c.Context(path, "") if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading config: %s", err)) + c.Ui.Error(fmt.Sprintf("Error loading Terraform: %s", err)) return 1 } - g, err := terraform.Graph(&terraform.GraphOpts{ - Config: conf, - Providers: c.ContextOpts.Providers, - }) + g, err := ctx.Graph() if err != nil { c.Ui.Error(fmt.Sprintf("Error creating graph: %s", err)) return 1 } + buf := new(bytes.Buffer) nodes := make([]digraph.Node, len(g.Nouns)) for i, n := range g.Nouns { nodes[i] = n } - digraph.GenerateDot(nodes, os.Stdout) + digraph.GenerateDot(nodes, buf) + + c.Ui.Output(buf.String()) return 0 } @@ -66,10 +74,14 @@ Usage: terraform graph [options] PATH shown. If the path is a plan file, then the dependency graph of the plan itself is shown. + The graph is outputted in DOT format. The typical program that can + read this format is GraphViz, but many web services are also available + to read this format. + ` return strings.TrimSpace(helpText) } func (c *GraphCommand) Synopsis() string { - return "Output visual graph of Terraform resources" + return "Create a visual graph of Terraform resources" } diff --git a/command/graph_test.go b/command/graph_test.go new file mode 100644 index 000000000..cbc564e8f --- /dev/null +++ b/command/graph_test.go @@ -0,0 +1,106 @@ +package command + +import ( + "os" + "strings" + "testing" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" +) + +func TestGraph(t *testing.T) { + ui := new(cli.MockUi) + c := &GraphCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + testFixturePath("graph"), + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if !strings.Contains(output, "digraph {") { + t.Fatalf("doesn't look like digraph: %s", output) + } +} + +func TestGraph_multipleArgs(t *testing.T) { + ui := new(cli.MockUi) + c := &GraphCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "bad", + "bad", + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + } +} + +func TestGraph_noArgs(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(testFixturePath("graph")); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + ui := new(cli.MockUi) + c := &GraphCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if !strings.Contains(output, "digraph {") { + t.Fatalf("doesn't look like digraph: %s", output) + } +} + +func TestGraph_plan(t *testing.T) { + planPath := testPlanFile(t, &terraform.Plan{ + Config: new(config.Config), + }) + + ui := new(cli.MockUi) + c := &GraphCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + planPath, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + output := ui.OutputWriter.String() + if !strings.Contains(output, "digraph {") { + t.Fatalf("doesn't look like digraph: %s", output) + } +} diff --git a/command/hook_ui.go b/command/hook_ui.go index 2c298feba..eba23c6e3 100644 --- a/command/hook_ui.go +++ b/command/hook_ui.go @@ -1,17 +1,22 @@ package command import ( + "bytes" "fmt" + "sort" + "strings" "sync" "github.com/hashicorp/terraform/terraform" "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" ) type UiHook struct { terraform.NilHook - Ui cli.Ui + Colorize *colorstring.Colorize + Ui cli.Ui once sync.Once ui cli.Ui @@ -23,15 +28,65 @@ func (h *UiHook) PreApply( d *terraform.ResourceDiff) (terraform.HookAction, error) { h.once.Do(h.init) - h.ui.Output(fmt.Sprintf("%s: Applying...", id)) + operation := "Modifying..." + if d.Destroy { + operation = "Destroying..." + } else if s.ID == "" { + operation = "Creating..." + } + + attrBuf := new(bytes.Buffer) + + // Get all the attributes that are changing, and sort them. Also + // determine the longest key so that we can align them all. + keyLen := 0 + keys := make([]string, 0, len(d.Attributes)) + for key, _ := range d.Attributes { + // Skip the ID since we do that specially + if key == "id" { + continue + } + + keys = append(keys, key) + if len(key) > keyLen { + keyLen = len(key) + } + } + sort.Strings(keys) + + // Go through and output each attribute + for _, attrK := range keys { + attrDiff := d.Attributes[attrK] + + v := attrDiff.New + if attrDiff.NewComputed { + v = "" + } + + attrBuf.WriteString(fmt.Sprintf( + " %s:%s %#v => %#v\n", + attrK, + strings.Repeat(" ", keyLen-len(attrK)), + attrDiff.Old, + v)) + } + + attrString := strings.TrimSpace(attrBuf.String()) + if attrString != "" { + attrString = "\n " + attrString + } + + h.ui.Output(h.Colorize.Color(fmt.Sprintf( + "[reset][bold]%s: %s[reset_bold]%s", + id, + operation, + attrString))) + return terraform.HookActionContinue, nil } func (h *UiHook) PreDiff( id string, s *terraform.ResourceState) (terraform.HookAction, error) { - h.once.Do(h.init) - - h.ui.Output(fmt.Sprintf("%s: Calculating diff", id)) return terraform.HookActionContinue, nil } @@ -39,11 +94,17 @@ func (h *UiHook) PreRefresh( id string, s *terraform.ResourceState) (terraform.HookAction, error) { h.once.Do(h.init) - h.ui.Output(fmt.Sprintf("%s: Refreshing state (ID: %s)", id, s.ID)) + h.ui.Output(h.Colorize.Color(fmt.Sprintf( + "[reset][bold]%s: Refreshing (ID: %s)", + id, s.ID))) return terraform.HookActionContinue, nil } func (h *UiHook) init() { + if h.Colorize == nil { + panic("colorize not given") + } + // Wrap the ui so that it is safe for concurrency regardless of the // underlying reader/writer that is in place. h.ui = &cli.ConcurrentUi{Ui: h.Ui} diff --git a/command/meta.go b/command/meta.go new file mode 100644 index 000000000..3bafdb7a8 --- /dev/null +++ b/command/meta.go @@ -0,0 +1,142 @@ +package command + +import ( + "fmt" + "os" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" + "github.com/mitchellh/colorstring" +) + +// Meta are the meta-options that are available on all or most commands. +type Meta struct { + Color bool + ContextOpts *terraform.ContextOpts + Ui cli.Ui + + oldUi cli.Ui +} + +// Colorize returns the colorization structure for a command. +func (m *Meta) Colorize() *colorstring.Colorize { + return &colorstring.Colorize{ + Colors: colorstring.DefaultColors, + Disable: !m.Color, + Reset: true, + } +} + +// Context returns a Terraform Context taking into account the context +// options used to initialize this meta configuration. +func (m *Meta) Context(path, statePath string) (*terraform.Context, error) { + opts := m.contextOpts() + + // First try to just read the plan directly from the path given. + f, err := os.Open(path) + if err == nil { + plan, err := terraform.ReadPlan(f) + f.Close() + if err == nil { + return plan.Context(opts), nil + } + } + + if statePath != "" { + if _, err := os.Stat(statePath); err != nil { + return nil, fmt.Errorf( + "There was an error reading the state file. The path\n"+ + "and error are shown below. If you're trying to build a\n"+ + "brand new infrastructure, explicitly pass the '-init'\n"+ + "flag to Terraform to tell it it is okay to build new\n"+ + "state.\n\n"+ + "Path: %s\n"+ + "Error: %s", + statePath, + err) + } + } + + // Load up the state + var state *terraform.State + if statePath != "" { + f, err := os.Open(statePath) + if err == nil { + state, err = terraform.ReadState(f) + f.Close() + } + + if err != nil { + return nil, fmt.Errorf("Error loading state: %s", err) + } + } + + config, err := config.LoadDir(path) + if err != nil { + return nil, fmt.Errorf("Error loading config: %s", err) + } + if err := config.Validate(); err != nil { + return nil, fmt.Errorf("Error validating config: %s", err) + } + + opts.Config = config + opts.State = state + ctx := terraform.NewContext(opts) + + if _, err := ctx.Plan(nil); err != nil { + return nil, fmt.Errorf("Error running plan: %s", err) + } + + return ctx, nil + +} + +// contextOpts returns the options to use to initialize a Terraform +// context with the settings from this Meta. +func (m *Meta) contextOpts() *terraform.ContextOpts { + var opts terraform.ContextOpts = *m.ContextOpts + opts.Hooks = make([]terraform.Hook, len(m.ContextOpts.Hooks)+1) + opts.Hooks[0] = m.uiHook() + copy(opts.Hooks[1:], m.ContextOpts.Hooks) + return &opts +} + +// process will process the meta-parameters out of the arguments. This +// will potentially modify the args in-place. It will return the resulting +// slice. +func (m *Meta) process(args []string) []string { + // We do this so that we retain the ability to technically call + // process multiple times, even if we have no plans to do so + if m.oldUi != nil { + m.Ui = m.oldUi + } + + // Set colorization + m.Color = true + for i, v := range args { + if v == "-no-color" { + m.Color = false + args = append(args[:i], args[i+1:]...) + break + } + } + + // Set the UI + m.oldUi = m.Ui + m.Ui = &ColorizeUi{ + Colorize: m.Colorize(), + ErrorColor: "[red]", + Ui: m.oldUi, + } + + return args +} + +// uiHook returns the UiHook to use with the context. +func (m *Meta) uiHook() *UiHook { + return &UiHook{ + Colorize: m.Colorize(), + Ui: m.Ui, + } +} diff --git a/command/meta_test.go b/command/meta_test.go new file mode 100644 index 000000000..6a2f680f3 --- /dev/null +++ b/command/meta_test.go @@ -0,0 +1,35 @@ +package command + +import ( + "reflect" + "testing" +) + +func TestMetaColorize(t *testing.T) { + var m *Meta + var args, args2 []string + + // Test basic, no change + m = new(Meta) + args = []string{"foo", "bar"} + args2 = []string{"foo", "bar"} + args = m.process(args) + if !reflect.DeepEqual(args, args2) { + t.Fatalf("bad: %#v", args) + } + if m.Colorize().Disable { + t.Fatal("should not be disabled") + } + + // Test disable #1 + m = new(Meta) + args = []string{"foo", "-no-color", "bar"} + args2 = []string{"foo", "bar"} + args = m.process(args) + if !reflect.DeepEqual(args, args2) { + t.Fatalf("bad: %#v", args) + } + if !m.Colorize().Disable { + t.Fatal("should be disabled") + } +} diff --git a/command/output.go b/command/output.go new file mode 100644 index 000000000..6c9d94b8e --- /dev/null +++ b/command/output.go @@ -0,0 +1,92 @@ +package command + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/hashicorp/terraform/terraform" +) + +// OutputCommand is a Command implementation that reads an output +// from a Terraform state and prints it. +type OutputCommand struct { + Meta +} + +func (c *OutputCommand) Run(args []string) int { + var statePath string + + args = c.Meta.process(args) + + cmdFlags := flag.NewFlagSet("output", flag.ContinueOnError) + cmdFlags.StringVar(&statePath, "state", DefaultStateFilename, "path") + cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } + if err := cmdFlags.Parse(args); err != nil { + return 1 + } + + args = cmdFlags.Args() + if len(args) != 1 || args[0] == "" { + c.Ui.Error( + "The output command expects exactly one argument with the name\n" + + "of an output variable.\n") + cmdFlags.Usage() + return 1 + } + name := args[0] + + f, err := os.Open(statePath) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error loading file: %s", err)) + return 1 + } + + state, err := terraform.ReadState(f) + f.Close() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error reading state: %s", err)) + return 1 + } + + if len(state.Outputs) == 0 { + c.Ui.Error(fmt.Sprintf( + "The state file has no outputs defined. Define an output\n" + + "in your configuration with the `output` directive and re-run\n" + + "`terraform apply` for it to become available.")) + return 1 + } + v, ok := state.Outputs[name] + if !ok { + c.Ui.Error(fmt.Sprintf( + "The output variable requested could not be found in the state\n" + + "file. If you recently added this to your configuration, be\n" + + "sure to run `terraform apply`, since the state won't be updated\n" + + "with new output variables until that command is run.")) + return 1 + } + + c.Ui.Output(v) + return 0 +} + +func (c *OutputCommand) Help() string { + helpText := ` +Usage: terraform output [options] NAME + + Reads an output variable from a Terraform state file and prints + the value. + +Options: + + -state=path Path to the state file to read. Defaults to + "terraform.tfstate". + +` + return strings.TrimSpace(helpText) +} + +func (c *OutputCommand) Synopsis() string { + return "Read an output from a state file" +} diff --git a/command/output_test.go b/command/output_test.go new file mode 100644 index 000000000..f177198c5 --- /dev/null +++ b/command/output_test.go @@ -0,0 +1,208 @@ +package command + +import ( + "io/ioutil" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" +) + +func TestOutput(t *testing.T) { + originalState := &terraform.State{ + Outputs: map[string]string{ + "foo": "bar", + }, + } + + statePath := testStateFile(t, originalState) + + ui := new(cli.MockUi) + c := &OutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "-state", statePath, + "foo", + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + actual := strings.TrimSpace(ui.OutputWriter.String()) + if actual != "bar" { + t.Fatalf("bad: %#v", actual) + } +} + +func TestOutput_badVar(t *testing.T) { + originalState := &terraform.State{ + Outputs: map[string]string{ + "foo": "bar", + }, + } + + statePath := testStateFile(t, originalState) + + ui := new(cli.MockUi) + c := &OutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "-state", statePath, + "bar", + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } +} + +func TestOutput_blank(t *testing.T) { + originalState := &terraform.State{ + Outputs: map[string]string{ + "foo": "bar", + }, + } + + statePath := testStateFile(t, originalState) + + ui := new(cli.MockUi) + c := &OutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "-state", statePath, + "", + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } +} + +func TestOutput_manyArgs(t *testing.T) { + ui := new(cli.MockUi) + c := &OutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "bad", + "bad", + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + } +} + +func TestOutput_noArgs(t *testing.T) { + ui := new(cli.MockUi) + c := &OutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{} + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + } +} + +func TestOutput_noVars(t *testing.T) { + originalState := &terraform.State{ + Outputs: map[string]string{}, + } + + statePath := testStateFile(t, originalState) + + ui := new(cli.MockUi) + c := &OutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "-state", statePath, + "bar", + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } +} + +func TestOutput_stateDefault(t *testing.T) { + originalState := &terraform.State{ + Outputs: map[string]string{ + "foo": "bar", + }, + } + + // Write the state file in a temporary directory with the + // default filename. + td, err := ioutil.TempDir("", "tf") + if err != nil { + t.Fatalf("err: %s", err) + } + statePath := filepath.Join(td, DefaultStateFilename) + + f, err := os.Create(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + err = terraform.WriteState(originalState, f) + f.Close() + if err != nil { + t.Fatalf("err: %s", err) + } + + // Change to that directory + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(filepath.Dir(statePath)); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + ui := new(cli.MockUi) + c := &OutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "foo", + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + actual := strings.TrimSpace(ui.OutputWriter.String()) + if actual != "bar" { + t.Fatalf("bad: %#v", actual) + } +} diff --git a/command/plan.go b/command/plan.go index 06cb9e7bb..44790f72b 100644 --- a/command/plan.go +++ b/command/plan.go @@ -7,77 +7,74 @@ import ( "os" "strings" - "github.com/hashicorp/terraform/config" "github.com/hashicorp/terraform/terraform" - "github.com/mitchellh/cli" ) // PlanCommand is a Command implementation that compares a Terraform // configuration to an actual infrastructure and shows the differences. type PlanCommand struct { - ContextOpts *terraform.ContextOpts - Ui cli.Ui + Meta } func (c *PlanCommand) Run(args []string) int { var destroy, refresh bool var outPath, statePath string + args = c.Meta.process(args) + cmdFlags := flag.NewFlagSet("plan", flag.ContinueOnError) cmdFlags.BoolVar(&destroy, "destroy", false, "destroy") cmdFlags.BoolVar(&refresh, "refresh", true, "refresh") cmdFlags.StringVar(&outPath, "out", "", "path") - cmdFlags.StringVar(&statePath, "state", "", "path") + cmdFlags.StringVar(&statePath, "state", DefaultStateFilename, "path") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } if err := cmdFlags.Parse(args); err != nil { return 1 } + var path string args = cmdFlags.Args() - if len(args) != 1 { + if len(args) > 1 { c.Ui.Error( - "The plan command expects only one argument with the path\n" + + "The plan command expects at most one argument with the path\n" + "to a Terraform configuration.\n") cmdFlags.Usage() return 1 + } else if len(args) == 1 { + path = args[0] + } else { + var err error + path, err = os.Getwd() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error getting pwd: %s", err)) + } } - // Load up the state - var state *terraform.State + // If the default state path doesn't exist, ignore it. if statePath != "" { - f, err := os.Open(statePath) - if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading state: %s", err)) - return 1 - } - - state, err = terraform.ReadState(f) - f.Close() - if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading state: %s", err)) - return 1 + if _, err := os.Stat(statePath); err != nil { + if os.IsNotExist(err) && statePath == DefaultStateFilename { + statePath = "" + } } } - b, err := config.Load(args[0]) + ctx, err := c.Context(path, statePath) if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading blueprint: %s", err)) + c.Ui.Error(err.Error()) return 1 } - - c.ContextOpts.Config = b - c.ContextOpts.Hooks = append(c.ContextOpts.Hooks, &UiHook{Ui: c.Ui}) - c.ContextOpts.State = state - ctx := terraform.NewContext(c.ContextOpts) if !validateContext(ctx, c.Ui) { return 1 } if refresh { + c.Ui.Output("Refreshing Terraform state prior to plan...\n") if _, err := ctx.Refresh(); err != nil { c.Ui.Error(fmt.Sprintf("Error refreshing state: %s", err)) return 1 } + c.Ui.Output("") } plan, err := ctx.Plan(&terraform.PlanOpts{Destroy: destroy}) @@ -87,12 +84,14 @@ func (c *PlanCommand) Run(args []string) int { } if plan.Diff.Empty() { - c.Ui.Output("No changes. Infrastructure is up-to-date.") + c.Ui.Output( + "No changes. Infrastructure is up-to-date. This means that Terraform\n" + + "could not detect any differences between your configuration and\n" + + "the real physical resources that exist. As a result, Terraform\n" + + "doesn't need to do anything.") return 0 } - c.Ui.Output(strings.TrimSpace(plan.String())) - if outPath != "" { log.Printf("[INFO] Writing plan output to: %s", outPath) f, err := os.Create(outPath) @@ -106,33 +105,75 @@ func (c *PlanCommand) Run(args []string) int { } } + if outPath == "" { + c.Ui.Output(strings.TrimSpace(planHeaderNoOutput) + "\n") + } else { + c.Ui.Output(fmt.Sprintf( + strings.TrimSpace(planHeaderYesOutput)+"\n", + outPath)) + } + + c.Ui.Output(FormatPlan(plan, c.Colorize())) + return 0 } func (c *PlanCommand) Help() string { helpText := ` -Usage: terraform plan [options] [terraform.tf] +Usage: terraform plan [options] [dir] - Shows the differences between the Terraform configuration and - the actual state of an infrastructure. + Generates an execution plan for Terraform. + + This execution plan can be reviewed prior to running apply to get a + sense for what Terraform will do. Optionally, the plan can be saved to + a Terraform plan file, and apply can take this plan file to execute + this plan exactly. Options: -destroy If set, a plan will be generated to destroy all resources managed by the given configuration and state. + -no-color If specified, output won't contain any color. + -out=path Write a plan file to the given path. This can be used as input to the "apply" command. -refresh=true Update state prior to checking for differences. -state=statefile Path to a Terraform state file to use to look - up Terraform-managed resources. + up Terraform-managed resources. By default it will + use the state "terraform.tfstate" if it exists. ` return strings.TrimSpace(helpText) } func (c *PlanCommand) Synopsis() string { - return "Show changes between Terraform config and infrastructure" + return "Generate and show an execution plan" } + +const planHeaderNoOutput = ` +The Terraform execution plan has been generated and is shown below. +Resources are shown in alphabetical order for quick scanning. Green resources +will be created (or destroyed and then created if an existing resource +exists), yellow resources are being changed in-place, and red resources +will be destroyed. + +Note: You didn't specify an "-out" parameter to save this plan, so when +"apply" is called, Terraform can't guarantee this is what will execute. +` + +const planHeaderYesOutput = ` +The Terraform execution plan has been generated and is shown below. +Resources are shown in alphabetical order for quick scanning. Green resources +will be created (or destroyed and then created if an existing resource +exists), yellow resources are being changed in-place, and red resources +will be destroyed. + +Your plan was also saved to the path below. Call the "apply" subcommand +with this plan file and Terraform will exactly execute this execution +plan. + +Path: %s +` diff --git a/command/plan_test.go b/command/plan_test.go index 84963d318..473f68a0c 100644 --- a/command/plan_test.go +++ b/command/plan_test.go @@ -3,6 +3,7 @@ package command import ( "io/ioutil" "os" + "path/filepath" "reflect" "testing" @@ -10,6 +11,31 @@ import ( "github.com/mitchellh/cli" ) +func TestPlan(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(testFixturePath("plan")); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + p := testProvider() + ui := new(cli.MockUi) + c := &PlanCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + } + + args := []string{} + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } +} + func TestPlan_destroy(t *testing.T) { originalState := &terraform.State{ Resources: map[string]*terraform.ResourceState{ @@ -26,8 +52,10 @@ func TestPlan_destroy(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &PlanCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } args := []string{ @@ -55,8 +83,10 @@ func TestPlan_noState(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &PlanCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } args := []string{ @@ -91,8 +121,10 @@ func TestPlan_outPath(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &PlanCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } p.DiffReturn = &terraform.ResourceDiff{ @@ -122,8 +154,10 @@ func TestPlan_refresh(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &PlanCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } args := []string{ @@ -166,8 +200,10 @@ func TestPlan_state(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &PlanCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } args := []string{ @@ -184,3 +220,64 @@ func TestPlan_state(t *testing.T) { t.Fatalf("bad: %#v", p.DiffState) } } + +func TestPlan_stateDefault(t *testing.T) { + originalState := &terraform.State{ + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + ID: "bar", + Type: "test_instance", + }, + }, + } + + // Write the state file in a temporary directory with the + // default filename. + td, err := ioutil.TempDir("", "tf") + if err != nil { + t.Fatalf("err: %s", err) + } + statePath := filepath.Join(td, DefaultStateFilename) + + f, err := os.Create(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + err = terraform.WriteState(originalState, f) + f.Close() + if err != nil { + t.Fatalf("err: %s", err) + } + + // Change to that directory + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(filepath.Dir(statePath)); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + p := testProvider() + ui := new(cli.MockUi) + c := &PlanCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + } + + args := []string{ + testFixturePath("plan"), + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + // Verify that the provider was called with the existing state + expectedState := originalState.Resources["test_instance.foo"] + if !reflect.DeepEqual(p.DiffState, expectedState) { + t.Fatalf("bad: %#v", p.DiffState) + } +} diff --git a/command/refresh.go b/command/refresh.go index a1229c9b7..256bf70dd 100644 --- a/command/refresh.go +++ b/command/refresh.go @@ -7,81 +7,94 @@ import ( "os" "strings" - "github.com/hashicorp/terraform/config" "github.com/hashicorp/terraform/terraform" - "github.com/mitchellh/cli" ) // RefreshCommand is a cli.Command implementation that refreshes the state // file. type RefreshCommand struct { - ContextOpts *terraform.ContextOpts - Ui cli.Ui + Meta } func (c *RefreshCommand) Run(args []string) int { - var outPath string - statePath := "terraform.tfstate" - configPath := "." + var statePath, stateOutPath string + + args = c.Meta.process(args) cmdFlags := flag.NewFlagSet("refresh", flag.ContinueOnError) - cmdFlags.StringVar(&outPath, "out", "", "output path") + cmdFlags.StringVar(&statePath, "state", DefaultStateFilename, "path") + cmdFlags.StringVar(&stateOutPath, "state-out", "", "path") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } if err := cmdFlags.Parse(args); err != nil { return 1 } + var configPath string args = cmdFlags.Args() - if len(args) != 2 { - // TODO(mitchellh): this is temporary until we can assume current - // dir for Terraform config. - c.Ui.Error("TEMPORARY: The refresh command requires two args.") + if len(args) > 1 { + c.Ui.Error("The apply command expacts at most one argument.") cmdFlags.Usage() return 1 + } else if len(args) == 1 { + configPath = args[0] + } else { + var err error + configPath, err = os.Getwd() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error getting pwd: %s", err)) + } } - statePath = args[0] - configPath = args[1] - if outPath == "" { - outPath = statePath + // If we don't specify an output path, default to out normal state + // path. + if stateOutPath == "" { + stateOutPath = statePath } - // Load up the state - f, err := os.Open(statePath) - if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading state: %s", err)) + // Verify that the state path exists. The "ContextArg" function below + // will actually do this, but we want to provide a richer error message + // if possible. + if _, err := os.Stat(statePath); err != nil { + if os.IsNotExist(err) { + c.Ui.Error(fmt.Sprintf( + "The Terraform state file for your infrastructure does not\n"+ + "exist. The 'refresh' command only works and only makes sense\n"+ + "when there is existing state that Terraform is managing. Please\n"+ + "double-check the value given below and try again. If you\n"+ + "haven't created infrastructure with Terraform yet, use the\n"+ + "'terraform apply' command.\n\n"+ + "Path: %s", + statePath)) + return 1 + } + + c.Ui.Error(fmt.Sprintf( + "There was an error reading the Terraform state that is needed\n"+ + "for refreshing. The path and error are shown below.\n\n"+ + "Path: %s\n\nError: %s", + statePath, + err)) return 1 } - state, err := terraform.ReadState(f) - f.Close() + // Build the context based on the arguments given + ctx, err := c.Context(configPath, statePath) if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading state: %s", err)) + c.Ui.Error(err.Error()) return 1 } - - b, err := config.Load(configPath) - if err != nil { - c.Ui.Error(fmt.Sprintf("Error loading blueprint: %s", err)) - return 1 - } - - c.ContextOpts.Config = b - c.ContextOpts.State = state - c.ContextOpts.Hooks = append(c.ContextOpts.Hooks, &UiHook{Ui: c.Ui}) - ctx := terraform.NewContext(c.ContextOpts) if !validateContext(ctx, c.Ui) { return 1 } - state, err = ctx.Refresh() + state, err := ctx.Refresh() if err != nil { c.Ui.Error(fmt.Sprintf("Error refreshing state: %s", err)) return 1 } - log.Printf("[INFO] Writing state output to: %s", outPath) - f, err = os.Create(outPath) + log.Printf("[INFO] Writing state output to: %s", stateOutPath) + f, err := os.Create(stateOutPath) if err == nil { defer f.Close() err = terraform.WriteState(state, f) @@ -96,21 +109,29 @@ func (c *RefreshCommand) Run(args []string) int { func (c *RefreshCommand) Help() string { helpText := ` -Usage: terraform refresh [options] [terraform.tfstate] [terraform.tf] +Usage: terraform refresh [options] [dir] - Refresh and update the state of your infrastructure. This is read-only - operation that will not modify infrastructure. The read-only property - is dependent on resource providers being implemented correctly. + Update the state file of your infrastructure with metadata that matches + the physical resources they are tracking. + + This will not modify your infrastructure, but it can modify your + state file to update metadata. This metadata might cause new changes + to occur when you generate a plan or call apply next. Options: - -out=path Path to write updated state file. If this is not specified, - the existing state file will be overridden. + -no-color If specified, output won't contain any color. + + -state=path Path to read and save state (unless state-out + is specified). Defaults to "terraform.tfstate". + + -state-out=path Path to write updated state file. By default, the + "-state" path will be used. ` return strings.TrimSpace(helpText) } func (c *RefreshCommand) Synopsis() string { - return "Refresh the state of your infrastructure" + return "Update local state file against real resources" } diff --git a/command/refresh_test.go b/command/refresh_test.go index f6332c5ad..7acf39e5f 100644 --- a/command/refresh_test.go +++ b/command/refresh_test.go @@ -3,6 +3,7 @@ package command import ( "io/ioutil" "os" + "path/filepath" "reflect" "testing" @@ -24,15 +25,17 @@ func TestRefresh(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &RefreshCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } p.RefreshFn = nil p.RefreshReturn = &terraform.ResourceState{ID: "yes"} args := []string{ - statePath, + "-state", statePath, testFixturePath("refresh"), } if code := c.Run(args); code != 0 { @@ -61,6 +64,165 @@ func TestRefresh(t *testing.T) { } } +func TestRefresh_badState(t *testing.T) { + p := testProvider() + ui := new(cli.MockUi) + c := &RefreshCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + } + + args := []string{ + "-state", "i-should-not-exist-ever", + testFixturePath("refresh"), + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } +} + +func TestRefresh_cwd(t *testing.T) { + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(testFixturePath("refresh")); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + state := &terraform.State{ + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + ID: "bar", + Type: "test_instance", + }, + }, + } + statePath := testStateFile(t, state) + + p := testProvider() + ui := new(cli.MockUi) + c := &RefreshCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + } + + p.RefreshFn = nil + p.RefreshReturn = &terraform.ResourceState{ID: "yes"} + + args := []string{ + "-state", statePath, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + if !p.RefreshCalled { + t.Fatal("refresh should be called") + } + + f, err := os.Open(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + + newState, err := terraform.ReadState(f) + f.Close() + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := newState.Resources["test_instance.foo"] + expected := p.RefreshReturn + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: %#v", actual) + } +} + +func TestRefresh_defaultState(t *testing.T) { + originalState := &terraform.State{ + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + ID: "bar", + Type: "test_instance", + }, + }, + } + + // Write the state file in a temporary directory with the + // default filename. + td, err := ioutil.TempDir("", "tf") + if err != nil { + t.Fatalf("err: %s", err) + } + statePath := filepath.Join(td, DefaultStateFilename) + + f, err := os.Create(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + err = terraform.WriteState(originalState, f) + f.Close() + if err != nil { + t.Fatalf("err: %s", err) + } + + // Change to that directory + cwd, err := os.Getwd() + if err != nil { + t.Fatalf("err: %s", err) + } + if err := os.Chdir(filepath.Dir(statePath)); err != nil { + t.Fatalf("err: %s", err) + } + defer os.Chdir(cwd) + + p := testProvider() + ui := new(cli.MockUi) + c := &RefreshCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, + } + + p.RefreshFn = nil + p.RefreshReturn = &terraform.ResourceState{ID: "yes"} + + args := []string{ + testFixturePath("refresh"), + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + } + + if !p.RefreshCalled { + t.Fatal("refresh should be called") + } + + f, err = os.Open(statePath) + if err != nil { + t.Fatalf("err: %s", err) + } + + newState, err := terraform.ReadState(f) + f.Close() + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := newState.Resources["test_instance.foo"] + expected := p.RefreshReturn + if !reflect.DeepEqual(actual, expected) { + t.Fatalf("bad: %#v", actual) + } +} + func TestRefresh_outPath(t *testing.T) { state := &terraform.State{ Resources: map[string]*terraform.ResourceState{ @@ -84,16 +246,18 @@ func TestRefresh_outPath(t *testing.T) { p := testProvider() ui := new(cli.MockUi) c := &RefreshCommand{ - ContextOpts: testCtxConfig(p), - Ui: ui, + Meta: Meta{ + ContextOpts: testCtxConfig(p), + Ui: ui, + }, } p.RefreshFn = nil p.RefreshReturn = &terraform.ResourceState{ID: "yes"} args := []string{ - "-out", outPath, - statePath, + "-state", statePath, + "-state-out", outPath, testFixturePath("refresh"), } if code := c.Run(args); code != 0 { diff --git a/command/show.go b/command/show.go new file mode 100644 index 000000000..6f7a895b8 --- /dev/null +++ b/command/show.go @@ -0,0 +1,100 @@ +package command + +import ( + "flag" + "fmt" + "os" + "strings" + + "github.com/hashicorp/terraform/terraform" +) + +// ShowCommand is a Command implementation that reads and outputs the +// contents of a Terraform plan or state file. +type ShowCommand struct { + Meta +} + +func (c *ShowCommand) Run(args []string) int { + args = c.Meta.process(args) + + cmdFlags := flag.NewFlagSet("show", flag.ContinueOnError) + 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( + "The show command expects exactly one argument with the path\n" + + "to a Terraform state or plan file.\n") + cmdFlags.Usage() + return 1 + } + path := args[0] + + var plan *terraform.Plan + var state *terraform.State + + f, err := os.Open(path) + if err != nil { + c.Ui.Error(fmt.Sprintf("Error loading file: %s", err)) + return 1 + } + + var planErr, stateErr error + plan, err = terraform.ReadPlan(f) + if err != nil { + if _, err := f.Seek(0, 0); err != nil { + c.Ui.Error(fmt.Sprintf("Error reading file: %s", err)) + return 1 + } + + plan = nil + planErr = err + } + if plan == nil { + state, err = terraform.ReadState(f) + if err != nil { + stateErr = err + } + } + if plan == nil && state == nil { + c.Ui.Error(fmt.Sprintf( + "Terraform couldn't read the given file as a state or plan file.\n"+ + "The errors while attempting to read the file as each format are\n"+ + "shown below.\n\n"+ + "State read error: %s\n\nPlan read error: %s", + stateErr, + planErr)) + return 1 + } + + if plan != nil { + c.Ui.Output(FormatPlan(plan, c.Colorize())) + return 0 + } + + c.Ui.Output(FormatState(state, c.Colorize())) + return 0 +} + +func (c *ShowCommand) Help() string { + helpText := ` +Usage: terraform show [options] path + + Reads and outputs a Terraform state or plan file in a human-readable + form. + +Options: + + -no-color If specified, output won't contain any color. + +` + return strings.TrimSpace(helpText) +} + +func (c *ShowCommand) Synopsis() string { + return "Inspect Terraform state or plan" +} diff --git a/command/show_test.go b/command/show_test.go new file mode 100644 index 000000000..c1bebf247 --- /dev/null +++ b/command/show_test.go @@ -0,0 +1,91 @@ +package command + +import ( + "testing" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/terraform" + "github.com/mitchellh/cli" +) + +func TestShow(t *testing.T) { + ui := new(cli.MockUi) + c := &ShowCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "bad", + "bad", + } + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + } +} + +func TestShow_noArgs(t *testing.T) { + ui := new(cli.MockUi) + c := &ShowCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{} + if code := c.Run(args); code != 1 { + t.Fatalf("bad: \n%s", ui.OutputWriter.String()) + } +} + +func TestShow_plan(t *testing.T) { + planPath := testPlanFile(t, &terraform.Plan{ + Config: new(config.Config), + }) + + ui := new(cli.MockUi) + c := &ShowCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + planPath, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } +} + +func TestShow_state(t *testing.T) { + originalState := &terraform.State{ + Resources: map[string]*terraform.ResourceState{ + "test_instance.foo": &terraform.ResourceState{ + ID: "bar", + Type: "test_instance", + }, + }, + } + + statePath := testStateFile(t, originalState) + + ui := new(cli.MockUi) + c := &ShowCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + statePath, + } + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } +} diff --git a/command/test-fixtures/graph/main.tf b/command/test-fixtures/graph/main.tf new file mode 100644 index 000000000..5794f94d9 --- /dev/null +++ b/command/test-fixtures/graph/main.tf @@ -0,0 +1,3 @@ +resource "test_instance" "foo" { + ami = "bar" +} diff --git a/command/version.go b/command/version.go index 4e433435f..999af7abf 100644 --- a/command/version.go +++ b/command/version.go @@ -3,24 +3,26 @@ package command import ( "bytes" "fmt" - - "github.com/mitchellh/cli" ) // VersionCommand is a Command implementation prints the version. type VersionCommand struct { + Meta + Revision string Version string VersionPrerelease string - Ui cli.Ui } func (c *VersionCommand) Help() string { return "" } -func (c *VersionCommand) Run(_ []string) int { +func (c *VersionCommand) Run(args []string) int { var versionString bytes.Buffer + + args = c.Meta.process(args) + fmt.Fprintf(&versionString, "Terraform v%s", c.Version) if c.VersionPrerelease != "" { fmt.Fprintf(&versionString, ".%s", c.VersionPrerelease) diff --git a/commands.go b/commands.go index 48d57cbfe..870ca39d1 100644 --- a/commands.go +++ b/commands.go @@ -26,42 +26,55 @@ func init() { Ui: &cli.BasicUi{Writer: os.Stdout}, } + meta := command.Meta{ + ContextOpts: &ContextOpts, + Ui: Ui, + } + Commands = map[string]cli.CommandFactory{ "apply": func() (cli.Command, error) { return &command.ApplyCommand{ - ShutdownCh: makeShutdownCh(), - ContextOpts: &ContextOpts, - Ui: Ui, + Meta: meta, + ShutdownCh: makeShutdownCh(), }, nil }, "graph": func() (cli.Command, error) { return &command.GraphCommand{ - ContextOpts: &ContextOpts, - Ui: Ui, + Meta: meta, + }, nil + }, + + "output": func() (cli.Command, error) { + return &command.OutputCommand{ + Meta: meta, }, nil }, "plan": func() (cli.Command, error) { return &command.PlanCommand{ - ContextOpts: &ContextOpts, - Ui: Ui, + Meta: meta, }, nil }, "refresh": func() (cli.Command, error) { return &command.RefreshCommand{ - ContextOpts: &ContextOpts, - Ui: Ui, + Meta: meta, + }, nil + }, + + "show": func() (cli.Command, error) { + return &command.ShowCommand{ + Meta: meta, }, nil }, "version": func() (cli.Command, error) { return &command.VersionCommand{ + Meta: meta, Revision: GitCommit, Version: Version, VersionPrerelease: VersionPrerelease, - Ui: Ui, }, nil }, } diff --git a/config/loader.go b/config/loader.go index 750e9d15d..a8cbb5adf 100644 --- a/config/loader.go +++ b/config/loader.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "path/filepath" ) @@ -35,6 +36,12 @@ func LoadDir(path string) (*Config, error) { return nil, err } + if len(matches) == 0 { + return nil, fmt.Errorf( + "No Terraform configuration files found in directory: %s", + path) + } + var result *Config for _, f := range matches { c, err := Load(f) diff --git a/config/loader_test.go b/config/loader_test.go index 2737ab38b..4181dcb34 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -128,6 +128,13 @@ func TestLoadDir_basic(t *testing.T) { } } +func TestLoadDir_noConfigs(t *testing.T) { + _, err := LoadDir(filepath.Join(fixtureDir, "dir-empty")) + if err == nil { + t.Fatal("should error") + } +} + func outputsStr(os map[string]*Output) string { ns := make([]string, 0, len(os)) for n, _ := range os { diff --git a/config/test-fixtures/dir-empty/.gitkeep b/config/test-fixtures/dir-empty/.gitkeep new file mode 100644 index 000000000..e69de29bb diff --git a/main.go b/main.go index 49331e192..2fa5b1760 100644 --- a/main.go +++ b/main.go @@ -91,7 +91,7 @@ func wrappedMain() int { // just show the version. args := os.Args[1:] for _, arg := range args { - if arg == "-v" || arg == "--version" { + if arg == "-v" || arg == "-version" || arg == "--version" { newArgs := make([]string, len(args)+1) newArgs[0] = "version" copy(newArgs[1:], args) diff --git a/terraform/context.go b/terraform/context.go index 5d6c07291..bf1980045 100644 --- a/terraform/context.go +++ b/terraform/context.go @@ -127,6 +127,11 @@ func (c *Context) Apply() (*State, error) { return c.state, err } +// Graph returns the graph for this context. +func (c *Context) Graph() (*depgraph.Graph, error) { + return c.graph() +} + // Plan generates an execution plan for the given context. // // The execution plan encapsulates the context and can be stored diff --git a/terraform/context_test.go b/terraform/context_test.go index 8051ac4b2..65e472de9 100644 --- a/terraform/context_test.go +++ b/terraform/context_test.go @@ -7,6 +7,28 @@ import ( "testing" ) +func TestContextGraph(t *testing.T) { + p := testProvider("aws") + config := testConfig(t, "validate-good") + c := testContext(t, &ContextOpts{ + Config: config, + Providers: map[string]ResourceProviderFactory{ + "aws": testProviderFuncFixed(p), + }, + }) + + g, err := c.Graph() + if err != nil { + t.Fatalf("err: %s", err) + } + + actual := strings.TrimSpace(g.String()) + expected := strings.TrimSpace(testContextGraph) + if actual != expected { + t.Fatalf("bad: %s", actual) + } +} + func TestContextValidate(t *testing.T) { p := testProvider("aws") config := testConfig(t, "validate-good") @@ -1605,3 +1627,15 @@ func testProvisioner() *MockResourceProvisioner { p := new(MockResourceProvisioner) return p } + +const testContextGraph = ` +root: root +aws_instance.bar + aws_instance.bar -> provider.aws +aws_instance.foo + aws_instance.foo -> provider.aws +provider.aws +root + root -> aws_instance.bar + root -> aws_instance.foo +` diff --git a/terraform/plan.go b/terraform/plan.go index 081fc6985..739561f1f 100644 --- a/terraform/plan.go +++ b/terraform/plan.go @@ -82,15 +82,31 @@ func (p *Plan) init() { // The format byte is prefixed into the plan file format so that we have // the ability in the future to change the file format if we want for any // reason. -const planFormatByte byte = 1 +const planFormatMagic = "tfplan" +const planFormatVersion byte = 1 // ReadPlan reads a plan structure out of a reader in the format that // was written by WritePlan. func ReadPlan(src io.Reader) (*Plan, error) { var result *Plan + var err error + n := 0 + // Verify the magic bytes + magic := make([]byte, len(planFormatMagic)) + for n < len(magic) { + n, err = src.Read(magic[n:]) + if err != nil { + return nil, fmt.Errorf("error while reading magic bytes: %s", err) + } + } + if string(magic) != planFormatMagic { + return nil, fmt.Errorf("not a valid plan file") + } + + // Verify the version is something we can read var formatByte [1]byte - n, err := src.Read(formatByte[:]) + n, err = src.Read(formatByte[:]) if err != nil { return nil, err } @@ -98,7 +114,7 @@ func ReadPlan(src io.Reader) (*Plan, error) { return nil, errors.New("failed to read plan version byte") } - if formatByte[0] != planFormatByte { + if formatByte[0] != planFormatVersion { return nil, fmt.Errorf("unknown plan file version: %d", formatByte[0]) } @@ -112,7 +128,17 @@ func ReadPlan(src io.Reader) (*Plan, error) { // WritePlan writes a plan somewhere in a binary format. func WritePlan(d *Plan, dst io.Writer) error { - n, err := dst.Write([]byte{planFormatByte}) + // Write the magic bytes so we can determine the file format later + n, err := dst.Write([]byte(planFormatMagic)) + if err != nil { + return err + } + if n != len(planFormatMagic) { + return errors.New("failed to write plan format magic bytes") + } + + // Write a version byte so we can iterate on version at some point + n, err = dst.Write([]byte{planFormatVersion}) if err != nil { return err } diff --git a/terraform/state.go b/terraform/state.go index 49dca37f1..8fef99889 100644 --- a/terraform/state.go +++ b/terraform/state.go @@ -149,15 +149,31 @@ func (s *sensitiveState) init() { // The format byte is prefixed into the state file format so that we have // the ability in the future to change the file format if we want for any // reason. -const stateFormatByte byte = 1 +const stateFormatMagic = "tfstate" +const stateFormatVersion byte = 1 // ReadState reads a state structure out of a reader in the format that // was written by WriteState. func ReadState(src io.Reader) (*State, error) { var result *State + var err error + n := 0 + // Verify the magic bytes + magic := make([]byte, len(stateFormatMagic)) + for n < len(magic) { + n, err = src.Read(magic[n:]) + if err != nil { + return nil, fmt.Errorf("error while reading magic bytes: %s", err) + } + } + if string(magic) != stateFormatMagic { + return nil, fmt.Errorf("not a valid state file") + } + + // Verify the version is something we can read var formatByte [1]byte - n, err := src.Read(formatByte[:]) + n, err = src.Read(formatByte[:]) if err != nil { return nil, err } @@ -165,10 +181,11 @@ func ReadState(src io.Reader) (*State, error) { return nil, errors.New("failed to read state version byte") } - if formatByte[0] != stateFormatByte { + if formatByte[0] != stateFormatVersion { return nil, fmt.Errorf("unknown state file version: %d", formatByte[0]) } + // Decode dec := gob.NewDecoder(src) if err := dec.Decode(&result); err != nil { return nil, err @@ -179,7 +196,17 @@ func ReadState(src io.Reader) (*State, error) { // WriteState writes a state somewhere in a binary format. func WriteState(d *State, dst io.Writer) error { - n, err := dst.Write([]byte{stateFormatByte}) + // Write the magic bytes so we can determine the file format later + n, err := dst.Write([]byte(stateFormatMagic)) + if err != nil { + return err + } + if n != len(stateFormatMagic) { + return errors.New("failed to write state format magic bytes") + } + + // Write a version byte so we can iterate on version at some point + n, err = dst.Write([]byte{stateFormatVersion}) if err != nil { return err }