From 0da4e8f4140c8bdd52fd2e638ef716069cfe0e33 Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Fri, 13 May 2016 11:16:59 -0700 Subject: [PATCH] command: Add "terraform remote output", allow all outputs w/modules Add the ability to get *separate* remote state via the "terraform remote output" subcommand. Syntax follows "terraform remote config", with a couple of additions. This allows the ability to extract remote state from Terraform in places were remote state may not be present, without having to pre-configure remote state. Also, as part of this work, there has been a re-factoring of the outputsAsString function to allow both "terraform output" and "terraform remote output" to print all outputs, even when an optional module is supplied. --- command/apply.go | 60 +--- command/output.go | 191 ++--------- command/output_helper.go | 222 +++++++++++++ command/output_helper_test.go | 309 ++++++++++++++++++ command/output_test.go | 57 ++++ command/refresh.go | 7 +- command/remote.go | 8 +- command/remote_output.go | 118 +++++++ command/remote_output_test.go | 251 ++++++++++++++ .../source/docs/commands/index.html.markdown | 2 +- .../docs/commands/remote-output.html.markdown | 40 +++ .../source/docs/commands/remote.html.markdown | 8 +- 12 files changed, 1044 insertions(+), 229 deletions(-) create mode 100644 command/output_helper.go create mode 100644 command/output_helper_test.go create mode 100644 command/remote_output.go create mode 100644 command/remote_output_test.go create mode 100644 website/source/docs/commands/remote-output.html.markdown diff --git a/command/apply.go b/command/apply.go index 83ae29a7f6a4..5abceabb746b 100644 --- a/command/apply.go +++ b/command/apply.go @@ -4,12 +4,10 @@ import ( "bytes" "fmt" "os" - "sort" "strings" "github.com/hashicorp/go-getter" "github.com/hashicorp/go-multierror" - "github.com/hashicorp/terraform/config" "github.com/hashicorp/terraform/terraform" ) @@ -250,8 +248,8 @@ func (c *ApplyCommand) Run(args []string) int { c.Meta.StateOutPath()))) } - if !c.Destroy { - if outputs := outputsAsString(state, ctx.Module().Config().Outputs, true); outputs != "" { + if !c.Destroy && state != nil { + if outputs := allOutputsAsString(state.RootModule(), ctx.Module().Config().Outputs, true); outputs != "" { c.Ui.Output(c.Colorize().Color(outputs)) } } @@ -376,57 +374,3 @@ Options: ` return strings.TrimSpace(helpText) } - -func outputsAsString(state *terraform.State, schema []*config.Output, includeHeader bool) string { - if state == nil { - return "" - } - - outputs := state.RootModule().Outputs - outputBuf := new(bytes.Buffer) - if len(outputs) > 0 { - schemaMap := make(map[string]*config.Output) - if schema != nil { - for _, s := range schema { - schemaMap[s.Name] = s - } - } - - if includeHeader { - outputBuf.WriteString("[reset][bold][green]\nOutputs:\n\n") - } - - // Output the outputs in alphabetical order - keyLen := 0 - ks := make([]string, 0, len(outputs)) - for key, _ := range outputs { - ks = append(ks, key) - if len(key) > keyLen { - keyLen = len(key) - } - } - sort.Strings(ks) - - for _, k := range ks { - schema, ok := schemaMap[k] - if ok && schema.Sensitive { - outputBuf.WriteString(fmt.Sprintf("%s = \n", k)) - continue - } - - v := outputs[k] - switch typedV := v.Value.(type) { - case string: - outputBuf.WriteString(fmt.Sprintf("%s = %s\n", k, typedV)) - case []interface{}: - outputBuf.WriteString(formatListOutput("", k, typedV)) - outputBuf.WriteString("\n") - case map[string]interface{}: - outputBuf.WriteString(formatMapOutput("", k, typedV)) - outputBuf.WriteString("\n") - } - } - } - - return strings.TrimSpace(outputBuf.String()) -} diff --git a/command/output.go b/command/output.go index 81ffd16e29b2..0526115a166d 100644 --- a/command/output.go +++ b/command/output.go @@ -1,11 +1,8 @@ package command import ( - "bytes" "flag" "fmt" - "sort" - "strconv" "strings" ) @@ -18,204 +15,70 @@ type OutputCommand struct { func (c *OutputCommand) Run(args []string) int { args = c.Meta.process(args, false) - var module string + var module, remoteBackend string + var remoteState bool + var backendConfig map[string]string cmdFlags := flag.NewFlagSet("output", flag.ContinueOnError) cmdFlags.StringVar(&c.Meta.statePath, "state", DefaultStateFilename, "path") cmdFlags.StringVar(&module, "module", "", "module") + cmdFlags.BoolVar(&remoteState, "remote", false, "remote") + cmdFlags.StringVar(&remoteBackend, "remote-backend", "atlas", "remote-backend") + cmdFlags.Var((*FlagKV)(&backendConfig), "remote-config", "remote-config") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } if err := cmdFlags.Parse(args); err != nil { return 1 } - args = cmdFlags.Args() - if len(args) > 2 { - c.Ui.Error( - "The output command expects exactly one argument with the name\n" + - "of an output variable or no arguments to show all outputs.\n") - cmdFlags.Usage() + name, index, err := parseOutputNameIndex(cmdFlags.Args()) + if err != nil { + c.Ui.Error(err.Error()) return 1 } - name := "" - if len(args) > 0 { - name = args[0] - } - - index := "" - if len(args) > 1 { - index = args[1] - } - stateStore, err := c.Meta.State() if err != nil { c.Ui.Error(fmt.Sprintf("Error reading state: %s", err)) return 1 } - - if module == "" { - module = "root" - } else { - module = "root." + module - } - - // Get the proper module we want to get outputs for - modPath := strings.Split(module, ".") - - state := stateStore.State() - mod := state.ModuleByPath(modPath) - - if mod == nil { - c.Ui.Error(fmt.Sprintf( - "The module %s could not be found. There is nothing to output.", - module)) - return 1 - } - - if state.Empty() || len(mod.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 - } - - if name == "" { - c.Ui.Output(outputsAsString(state, nil, false)) - return 0 - } - - v, ok := mod.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.")) + mod, err := moduleFromState(stateStore.State(), module) + if err != nil { + c.Ui.Error(err.Error()) return 1 } - switch output := v.Value.(type) { - case string: - c.Ui.Output(output) - return 0 - case []interface{}: - if index == "" { - c.Ui.Output(formatListOutput("", "", output)) - break - } - - indexInt, err := strconv.Atoi(index) + var out string + if name != "" { + out, err = singleOutputAsString(mod, name, index) if err != nil { - c.Ui.Error(fmt.Sprintf( - "The index %q requested is not valid for the list output\n"+ - "%q - indices must be numeric, and in the range 0-%d", index, name, - len(output)-1)) - break - } - - if indexInt < 0 || indexInt >= len(output) { - c.Ui.Error(fmt.Sprintf( - "The index %d requested is not valid for the list output\n"+ - "%q - indices must be in the range 0-%d", indexInt, name, - len(output)-1)) - break - } - - c.Ui.Output(fmt.Sprintf("%s", output[indexInt])) - return 0 - case map[string]interface{}: - if index == "" { - c.Ui.Output(formatMapOutput("", "", output)) - break - } - - if value, ok := output[index]; ok { - c.Ui.Output(fmt.Sprintf("%s", value)) - return 0 - } else { + c.Ui.Error(err.Error()) return 1 } - default: - c.Ui.Error(fmt.Sprintf("Unknown output type: %T", v.Type)) - return 1 - } - - return 0 -} - -func formatListOutput(indent, outputName string, outputList []interface{}) string { - keyIndent := "" - - outputBuf := new(bytes.Buffer) - - if outputName != "" { - outputBuf.WriteString(fmt.Sprintf("%s%s = [", indent, outputName)) - keyIndent = " " - } - - for _, value := range outputList { - outputBuf.WriteString(fmt.Sprintf("\n%s%s%s", indent, keyIndent, value)) - } - - if outputName != "" { - if len(outputList) > 0 { - outputBuf.WriteString(fmt.Sprintf("\n%s]", indent)) - } else { - outputBuf.WriteString("]") - } - } - - return strings.TrimPrefix(outputBuf.String(), "\n") -} - -func formatMapOutput(indent, outputName string, outputMap map[string]interface{}) string { - ks := make([]string, 0, len(outputMap)) - for k, _ := range outputMap { - ks = append(ks, k) - } - sort.Strings(ks) - - keyIndent := "" - - outputBuf := new(bytes.Buffer) - if outputName != "" { - outputBuf.WriteString(fmt.Sprintf("%s%s = {", indent, outputName)) - keyIndent = " " - } - - for _, k := range ks { - v := outputMap[k] - outputBuf.WriteString(fmt.Sprintf("\n%s%s%s = %v", indent, keyIndent, k, v)) + } else { + out = allOutputsAsString(mod, nil, false) } - if outputName != "" { - if len(outputMap) > 0 { - outputBuf.WriteString(fmt.Sprintf("\n%s}", indent)) - } else { - outputBuf.WriteString("}") - } - } + c.Ui.Output(out) - return strings.TrimPrefix(outputBuf.String(), "\n") + 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. If NAME is not specified, all outputs are printed. + Reads an output variable from a Terraform state file, or remote state, + and prints the value. If NAME is not specified, all outputs are printed. Options: - -state=path Path to the state file to read. Defaults to - "terraform.tfstate". + -state=path Path to the state file to read. Defaults to + "terraform.tfstate". - -no-color If specified, output won't contain any color. + -no-color If specified, output won't contain any color. - -module=name If specified, returns the outputs for a - specific module + -module=name If specified, returns the outputs for a + specific module. ` return strings.TrimSpace(helpText) diff --git a/command/output_helper.go b/command/output_helper.go new file mode 100644 index 000000000000..779df5c33427 --- /dev/null +++ b/command/output_helper.go @@ -0,0 +1,222 @@ +package command + +import ( + "bytes" + "fmt" + "sort" + "strconv" + "strings" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/terraform" +) + +// parseOutputName extracts the name and index from the remaining arguments in +// the output command. +func parseOutputNameIndex(args []string) (string, string, error) { + if len(args) > 2 { + return "", "", fmt.Errorf( + "This command expects exactly one argument with the name\n" + + "of an output variable or no arguments to show all outputs.\n") + } + + name := "" + if len(args) > 0 { + name = args[0] + } + + index := "" + + if len(args) > 1 { + index = args[1] + } + + return name, index, nil +} + +// moduleFromState returns a module from a Terraform state. +func moduleFromState(state *terraform.State, module string) (*terraform.ModuleState, error) { + if module == "" { + module = "root" + } else { + module = "root." + module + } + + // Get the proper module we want to get outputs for + modPath := strings.Split(module, ".") + mod := state.ModuleByPath(modPath) + + if mod == nil { + return nil, fmt.Errorf("The module %s could not be found. There is nothing to output.", module) + } + + if state.Empty() || len(mod.Outputs) == 0 { + return nil, fmt.Errorf( + "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 mod, nil +} + +// singleOutputAsString looks for a single output in a module path and outputs +// as a string. +func singleOutputAsString(mod *terraform.ModuleState, name, index string) (string, error) { + v, ok := mod.Outputs[name] + if !ok { + return "", fmt.Errorf( + "The output variable requested could not be found in the state.\n" + + "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.") + } + + var s string + switch output := v.Value.(type) { + case string: + s = output + case []interface{}: + if index == "" { + s = formatListOutput("", "", output) + break + } + + indexInt, err := strconv.Atoi(index) + if err != nil { + return "", fmt.Errorf( + "The index %q requested is not valid for the list output\n"+ + "%q - indices must be numeric, and in the range 0-%d", index, name, + len(output)-1) + } + + if indexInt < 0 || indexInt >= len(output) { + return "", fmt.Errorf( + "The index %d requested is not valid for the list output\n"+ + "%q - indices must be in the range 0-%d", indexInt, name, + len(output)-1) + } + + s = fmt.Sprintf("%s", output[indexInt]) + case map[string]interface{}: + if index == "" { + s = formatMapOutput("", "", output) + } + + if value, ok := output[index]; ok { + s = fmt.Sprintf("%s", value) + } else { + return "", fmt.Errorf("") + } + default: + panic(fmt.Errorf("Unknown output type: %T", output)) + } + return s, nil +} + +func formatListOutput(indent, outputName string, outputList []interface{}) string { + keyIndent := "" + + outputBuf := new(bytes.Buffer) + if outputName != "" { + outputBuf.WriteString(fmt.Sprintf("%s%s = [", indent, outputName)) + keyIndent = " " + } + + for _, value := range outputList { + outputBuf.WriteString(fmt.Sprintf("\n%s%s%s", indent, keyIndent, value)) + } + + if outputName != "" { + if len(outputList) > 0 { + outputBuf.WriteString(fmt.Sprintf("\n%s]", indent)) + } else { + outputBuf.WriteString("]") + } + } + + return strings.TrimPrefix(outputBuf.String(), "\n") +} + +func formatMapOutput(indent, outputName string, outputMap map[string]interface{}) string { + ks := make([]string, 0, len(outputMap)) + for k := range outputMap { + ks = append(ks, k) + } + sort.Strings(ks) + + keyIndent := "" + + outputBuf := new(bytes.Buffer) + if outputName != "" { + outputBuf.WriteString(fmt.Sprintf("%s%s = {", indent, outputName)) + keyIndent = " " + } + + for _, k := range ks { + v := outputMap[k] + outputBuf.WriteString(fmt.Sprintf("\n%s%s%s = %v", indent, keyIndent, k, v)) + } + + if outputName != "" { + if len(outputMap) > 0 { + outputBuf.WriteString(fmt.Sprintf("\n%s}", indent)) + } else { + outputBuf.WriteString("}") + } + } + + return strings.TrimPrefix(outputBuf.String(), "\n") +} + +// allOutputsAsString returns all outputs, pretty formatted, for a given +// module path. +func allOutputsAsString(mod *terraform.ModuleState, schema []*config.Output, includeHeader bool) string { + outputs := mod.Outputs + outputBuf := new(bytes.Buffer) + if len(outputs) > 0 { + schemaMap := make(map[string]*config.Output) + if schema != nil { + for _, s := range schema { + schemaMap[s.Name] = s + } + } + + if includeHeader { + outputBuf.WriteString("[reset][bold][green]\nOutputs:\n\n") + } + + // Output the outputs in alphabetical order + keyLen := 0 + ks := make([]string, 0, len(outputs)) + for key := range outputs { + ks = append(ks, key) + if len(key) > keyLen { + keyLen = len(key) + } + } + sort.Strings(ks) + + for _, k := range ks { + schema, ok := schemaMap[k] + if ok && schema.Sensitive { + outputBuf.WriteString(fmt.Sprintf("%s = \n", k)) + continue + } + + v := outputs[k] + switch typedV := v.Value.(type) { + case string: + outputBuf.WriteString(fmt.Sprintf("%s = %s\n", k, typedV)) + case []interface{}: + outputBuf.WriteString(formatListOutput("", k, typedV)) + outputBuf.WriteString("\n") + case map[string]interface{}: + outputBuf.WriteString(formatMapOutput("", k, typedV)) + outputBuf.WriteString("\n") + } + } + } + + return strings.TrimSpace(outputBuf.String()) +} diff --git a/command/output_helper_test.go b/command/output_helper_test.go new file mode 100644 index 000000000000..db1090d1841b --- /dev/null +++ b/command/output_helper_test.go @@ -0,0 +1,309 @@ +package command + +import ( + "reflect" + "testing" + + "github.com/hashicorp/terraform/config" + "github.com/hashicorp/terraform/terraform" +) + +// testStateConfig provides a mock state for testing. +func testStateConfig() *terraform.State { + return &terraform.State{ + Modules: []*terraform.ModuleState{ + &terraform.ModuleState{ + Path: []string{"root"}, + Outputs: map[string]*terraform.OutputState{ + "foo": &terraform.OutputState{ + Value: "bar", + }, + }, + }, + &terraform.ModuleState{ + Path: []string{"root", "my_module"}, + Outputs: map[string]*terraform.OutputState{ + "blah": &terraform.OutputState{ + Value: "tastatur", + }, + }, + }, + }, + } +} + +// testModuleStateConfig provides a mock ModuleState for testing. +func testModuleStateConfig() *terraform.ModuleState { + return &terraform.ModuleState{ + Path: []string{"root", "my_module"}, + Outputs: map[string]*terraform.OutputState{ + "foo": &terraform.OutputState{ + Value: "bar", + }, + "baz": &terraform.OutputState{ + Value: "qux", + }, + "listoutput": &terraform.OutputState{ + Value: []interface{}{"one", "two"}, + }, + "mapoutput": &terraform.OutputState{ + Value: map[string]interface{}{ + "key": "value", + }, + }, + "emptylist": &terraform.OutputState{ + Value: []interface{}{}, + }, + "emptymap": &terraform.OutputState{ + Value: map[string]interface{}{}, + }, + "emptystring": &terraform.OutputState{ + Value: "", + }, + }, + } +} + +// testOutputSchemaConfig provides a mock []*config.Output for testing. +func testOutputSchemaConfig() []*config.Output { + return []*config.Output{ + &config.Output{ + Name: "foo", + Sensitive: false, + }, + &config.Output{ + Name: "baz", + Sensitive: true, + }, + &config.Output{ + Name: "listoutput", + Sensitive: false, + }, + &config.Output{ + Name: "mapoutput", + Sensitive: false, + }, + &config.Output{ + Name: "emptylist", + Sensitive: false, + }, + &config.Output{ + Name: "emptymap", + Sensitive: false, + }, + &config.Output{ + Name: "emptystring", + Sensitive: false, + }, + } +} + +const testOutputAsStringExpected = `baz = +emptylist = [] +emptymap = {} +emptystring = +foo = bar +listoutput = [ + one + two +] +mapoutput = { + key = value +}` + +func TestOutputHelper_parseOutputNameIndex(t *testing.T) { + name, index, err := parseOutputNameIndex([]string{"foo", "2"}) + + if err != nil { + t.Fatalf("bad: %s", err.Error()) + } + + if name != "foo" { + t.Fatalf("expected name to be foo, got %s", name) + } + + if index != "2" { + t.Fatalf("expected index to be 2, got %s", index) + } +} + +func TestOutputHelper_parseOutputNameIndex_noArgs(t *testing.T) { + name, index, err := parseOutputNameIndex([]string{}) + + if err != nil { + t.Fatalf("bad: %s", err.Error()) + } + + if name != "" { + t.Fatalf("expected name to be foo, got %s", name) + } + + if index != "" { + t.Fatalf("expected index to be 2, got %s", index) + } +} + +func TestOutputHelper_parseOutputNameIndex_tooManyArgs(t *testing.T) { + name, index, err := parseOutputNameIndex([]string{"foo", "2", "bar"}) + + if err == nil { + t.Fatalf("bad: %s, %s", name, index) + } + + expected := `This command expects exactly one argument with the name +of an output variable or no arguments to show all outputs. +` + if err.Error() != expected { + t.Fatalf("Expected error to be %s, got %s", expected, err.Error()) + } +} + +func TestOutputHelper_moduleFromState(t *testing.T) { + originalState := testStateConfig() + mod, err := moduleFromState(originalState, "my_module") + + if err != nil { + t.Fatalf("bad: %s", err.Error()) + } + + expected := []string{"root", "my_module"} + + if reflect.DeepEqual(mod.Path, expected) != true { + t.Fatalf("Expected module path to be %v, got %v", expected, mod.Path) + } +} + +func TestOutputHelper_moduleFromState_badModule(t *testing.T) { + originalState := testStateConfig() + mod, err := moduleFromState(originalState, "wrong_module") + + if err == nil { + t.Fatalf("expected error, got %v", mod) + } + + expected := "The module root.wrong_module could not be found. There is nothing to output." + + if err.Error() != expected { + t.Fatalf("Expected error to be %s, got %s", expected, err.Error()) + } +} + +func TestOutputHelper_moduleFromState_emptyState(t *testing.T) { + originalState := testStateConfig() + originalState.Modules[0].Outputs = map[string]*terraform.OutputState{} + mod, err := moduleFromState(originalState, "") + + if err == nil { + t.Fatalf("expected error, got %v", mod) + } + + expected := `The state file has no outputs defined. Define an output +in your configuration with the ` + "`output`" + ` directive and re-run +` + "`terraform apply`" + ` for it to become available.` + + if err.Error() != expected { + t.Fatalf("Expected error to be %s, got %s", expected, err.Error()) + } +} + +func TestOutputHelper_singleOutputAsString(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "foo", "0") + if err != nil { + t.Fatalf("bad: %s", err.Error()) + } + + if out != "bar" { + t.Fatalf("expected out to be bar, got %s", out) + } +} + +func TestOutputHelper_singleOutputAsString_notFound(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "nonexistent", "0") + if err == nil { + t.Fatalf("expected error, got %v", out) + } + + expected := `The output variable requested could not be found in the state. +If you recently added this to your configuration, be +sure to run ` + "`terraform apply`," + ` since the state won't be updated +with new output variables until that command is run.` + + if err.Error() != expected { + t.Fatalf("Expected error to be %s, got %s", expected, err.Error()) + } +} + +func TestOutputHelper_singleOutputAsString_list(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "listoutput", "0") + if err != nil { + t.Fatalf("bad: %s", err.Error()) + } + + if out != "one" { + t.Fatalf("expected out to be one, got %s", out) + } +} + +func TestOutputHelper_singleOutputAsString_listAllEntries(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "listoutput", "") + if err != nil { + t.Fatalf("bad: %s", err.Error()) + } + + if out != "one\ntwo" { + t.Fatalf("expected out to be one\\ntwo, got %s", out) + } +} + +func TestOutputHelper_singleOutputAsString_listBadIndex(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "listoutput", "nope") + if err == nil { + t.Fatalf("expected error, got %v", out) + } + + expected := `The index "nope" requested is not valid for the list output +"listoutput" - indices must be numeric, and in the range 0-1` + + if err.Error() != expected { + t.Fatalf("Expected error to be %s, got %s", expected, err.Error()) + } +} + +func TestOutputHelper_singleOutputAsString_listOutOfRange(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "listoutput", "100") + if err == nil { + t.Fatalf("expected error, got %v", out) + } + + expected := `The index 100 requested is not valid for the list output +"listoutput" - indices must be in the range 0-1` + + if err.Error() != expected { + t.Fatalf("Expected error to be %s, got %s", expected, err.Error()) + } +} + +func TestOutputHelper_allOutputsAsString(t *testing.T) { + mod := testModuleStateConfig() + schema := testOutputSchemaConfig() + + text := allOutputsAsString(mod, schema, false) + + expected := testOutputAsStringExpected + actual := text + if expected != actual { + t.Fatalf("Expected output: %q\ngiven: \n%q", expected, actual) + } +} diff --git a/command/output_test.go b/command/output_test.go index c553ff5aa459..6aa0da8efac7 100644 --- a/command/output_test.go +++ b/command/output_test.go @@ -4,6 +4,8 @@ import ( "io/ioutil" "os" "path/filepath" + "reflect" + "sort" "strings" "testing" @@ -100,6 +102,61 @@ func TestModuleOutput(t *testing.T) { } } +func TestModuleOutput_allOutputs(t *testing.T) { + originalState := &terraform.State{ + Modules: []*terraform.ModuleState{ + &terraform.ModuleState{ + Path: []string{"root"}, + Outputs: map[string]*terraform.OutputState{ + "foo": &terraform.OutputState{ + Value: "bar", + }, + }, + }, + &terraform.ModuleState{ + Path: []string{"root", "my_module"}, + Outputs: map[string]*terraform.OutputState{ + "blah": &terraform.OutputState{ + Value: "tastatur", + }, + "baz": &terraform.OutputState{ + Value: "qux", + }, + }, + }, + }, + } + + statePath := testStateFile(t, originalState) + + ui := new(cli.MockUi) + c := &OutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "-state", statePath, + "-module", "my_module", + "blah", + } + + if code := c.Run(args); code != 0 { + t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) + } + + expectedOutput := strings.Split("\n", "blah = tastatur\nbaz = qux\n") + sort.Strings(expectedOutput) + + output := strings.Split("\n", ui.OutputWriter.String()) + sort.Strings(output) + if reflect.DeepEqual(output, expectedOutput) != true { + t.Fatalf("Expected output: %#v\ngiven: %#v", expectedOutput, output) + } +} + func TestMissingModuleOutput(t *testing.T) { originalState := &terraform.State{ Modules: []*terraform.ModuleState{ diff --git a/command/refresh.go b/command/refresh.go index 0c41bcbe43ca..bdb53cc65f61 100644 --- a/command/refresh.go +++ b/command/refresh.go @@ -109,10 +109,11 @@ func (c *RefreshCommand) Run(args []string) int { return 1 } - if outputs := outputsAsString(newState, ctx.Module().Config().Outputs, true); outputs != "" { - c.Ui.Output(c.Colorize().Color(outputs)) + if newState != nil { + if outputs := allOutputsAsString(newState.RootModule(), ctx.Module().Config().Outputs, true); outputs != "" { + c.Ui.Output(c.Colorize().Color(outputs)) + } } - return 0 } diff --git a/command/remote.go b/command/remote.go index 05d119341e99..e1903689437d 100644 --- a/command/remote.go +++ b/command/remote.go @@ -24,6 +24,9 @@ func (c *RemoteCommand) Run(argsRaw []string) int { case "config": cmd := &RemoteConfigCommand{Meta: c.Meta} return cmd.Run(args[1:]) + case "output": + cmd := &RemoteOutputCommand{Meta: c.Meta} + return cmd.Run(args[1:]) case "pull": cmd := &RemotePullCommand{Meta: c.Meta} return cmd.Run(args[1:]) @@ -40,7 +43,7 @@ func (c *RemoteCommand) Help() string { helpText := ` Usage: terraform remote [options] - Configure remote state storage with Terraform. + Configure remote state storage with Terraform, or read a remote state. Options: @@ -49,6 +52,7 @@ Options: Available subcommands: config Configure the remote storage settings. + output Reads any remote state (even if not currently configured). pull Sync the remote storage by downloading to local storage. push Sync the remote storage by uploading the local storage. @@ -57,5 +61,5 @@ Available subcommands: } func (c *RemoteCommand) Synopsis() string { - return "Configure remote state storage" + return "Configure remote state storage, or read a remote state" } diff --git a/command/remote_output.go b/command/remote_output.go new file mode 100644 index 000000000000..d4df5d5799b3 --- /dev/null +++ b/command/remote_output.go @@ -0,0 +1,118 @@ +package command + +import ( + "flag" + "fmt" + "strings" + + "github.com/hashicorp/terraform/state/remote" + "github.com/hashicorp/terraform/terraform" +) + +// RemoteOutputCommand is a Command implementation that is used to +// read a Terraform remote state. +type RemoteOutputCommand struct { + Meta +} + +// Run runs the terraform remote output command. +func (c *RemoteOutputCommand) Run(args []string) int { + config := make(map[string]string) + var module, backend string + + args = c.Meta.process(args, false) + cmdFlags := flag.NewFlagSet("remote output", flag.ContinueOnError) + cmdFlags.StringVar(&backend, "backend", "atlas", "backend") + cmdFlags.Var((*FlagKV)(&config), "backend-config", "config") + cmdFlags.StringVar(&module, "module", "", "module") + cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } + if err := cmdFlags.Parse(args); err != nil { + c.Ui.Error(fmt.Sprintf("\nError parsing CLI flags: %s", err)) + return 1 + } + + name, index, err := parseOutputNameIndex(cmdFlags.Args()) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + // Lowercase the type + backend = strings.ToLower(backend) + + state, err := getState(backend, config) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + mod, err := moduleFromState(state, module) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + + var out string + if name != "" { + out, err = singleOutputAsString(mod, name, index) + if err != nil { + c.Ui.Error(err.Error()) + return 1 + } + } else { + out = allOutputsAsString(mod, nil, false) + } + + c.Ui.Output(out) + + return 0 +} + +// getState loads a Terraform state, provided a backend and config. +func getState(backend string, config map[string]string) (*terraform.State, error) { + client, err := remote.NewClient(backend, config) + if err != nil { + return nil, err + } + + s := &remote.State{Client: client} + if err := s.RefreshState(); err != nil { + return nil, err + } + + return s.State(), nil +} + +// Help displays the help text for the terraform remote output command. +func (c *RemoteOutputCommand) Help() string { + helpText := ` +Usage: terraform remote output [options] [NAME] + + Reads an output variable from the specified Terraform remote state. Does + not read or alter your existing configruation, and can be used without + any remote state configured. + + If NAME is not specified, all outputs are printed. + +Options: + + -backend=Atlas Specifies the type of remote backend. See + "terraform remote config -help" for a list of + supported backends. Defaults to Atlas. + + -config="k=v" Specifies configuration for the remote storage + backend. This can be specified multiple times. + + -no-color If specified, output won't contain any color. + + -module=name If specified, returns the outputs for a + specific module. + +` + return strings.TrimSpace(helpText) +} + +// Synopsis displays the synopsis text for the terraform remote output command. +func (c *RemoteOutputCommand) Synopsis() string { + return "Reads a Terraform remote state" +} diff --git a/command/remote_output_test.go b/command/remote_output_test.go new file mode 100644 index 000000000000..a74d66d3a8ce --- /dev/null +++ b/command/remote_output_test.go @@ -0,0 +1,251 @@ +package command + +import ( + "net/http" + "net/http/httptest" + "reflect" + "sort" + "strings" + "testing" + + "github.com/mitchellh/cli" +) + +const remoteStateResponseText = ` +{ + "version": 1, + "serial": 1, + "remote": { + "type": "http", + "config": { + "address": "http://127.0.0.1:12345/", + "skip_cert_verification": "0" + } + }, + "modules": [{ + "path": [ + "root" + ], + "outputs": { + "foo": "bar", + "baz": "qux" + }, + "resources": {} + },{ + "path": [ + "root", + "my_module" + ], + "outputs": { + "blah": "tastatur", + "baz": "qux" + }, + "resources": {} + }] +} +` + +const remoteStateResponseTextNoState = ` +{ + "version": 0, + "serial": 0, + "remote": { + "type": "http", + "config": { + "address": "http://127.0.0.1:12345/", + "skip_cert_verification": "0" + } + }, + "modules": [] +} +` + +const remoteStateResponseTextNoVars = ` +{ + "version": 1, + "serial": 1, + "remote": { + "type": "http", + "config": { + "address": "http://127.0.0.1:12345/", + "skip_cert_verification": "0" + } + }, + "modules": [{ + "path": [ + "root" + ], + "outputs": {}, + "resources": {} + } +} +` + +// newRemoteStateHTTPTestServer retuns a HTTP test server. +func newRemoteStateHTTPTestServer(f func(w http.ResponseWriter, r *http.Request)) *httptest.Server { + ts := httptest.NewServer(http.HandlerFunc(f)) + return ts +} + +// httpRemoteStateTestServer returns a fully configured HTTP test server for +// HTTP remote state. +func httpRemoteStateTestServer(response string) *httptest.Server { + return newRemoteStateHTTPTestServer(func(w http.ResponseWriter, r *http.Request) { + w.Header().Add("Content-Type", "application/json") + http.Error(w, response, http.StatusOK) + }) +} + +// runTestRemoteOutputRequest is a helper function that performs the common +// tasks of setting up the HTTP test server and sending the +// "terraform remote output" command, and returns the exit code and output. +func runTestRemoteOutputRequest(extraArgs []string, response string) (string, int) { + ts := httpRemoteStateTestServer(response) + defer ts.Close() + + ui := new(cli.MockUi) + c := &RemoteOutputCommand{ + Meta: Meta{ + ContextOpts: testCtxConfig(testProvider()), + Ui: ui, + }, + } + + args := []string{ + "-backend=http", + "-backend-config=address=" + ts.URL, + } + + for _, v := range extraArgs { + args = append(args, v) + } + + code := c.Run(args) + out := ui.OutputWriter.String() + + return out, code +} + +func TestRemoteOutput(t *testing.T) { + text, code := runTestRemoteOutputRequest([]string{}, remoteStateResponseText) + + if code != 0 { + t.Fatalf("bad: \n%s", text) + } + + // Our output needs to be sorted here, remote state returns unordered. + expectedOutput := strings.Split("\n", "foo = bar\nbaz = qux\n") + sort.Strings(expectedOutput) + + output := strings.Split("\n", text) + sort.Strings(output) + if reflect.DeepEqual(output, expectedOutput) != true { + t.Fatalf("Expected output: %#v\ngiven: %#v", expectedOutput, output) + } +} + +func TestRemoteOutput_moduleSingle(t *testing.T) { + args := []string{ + "-module", "my_module", + "blah", + } + + text, code := runTestRemoteOutputRequest(args, remoteStateResponseText) + + if code != 0 { + t.Fatalf("bad: \n%s", text) + } + + actual := strings.TrimSpace(text) + if actual != "tastatur" { + t.Fatalf("bad: %#v", actual) + } +} + +func TestRemoteOutput_moduleAll(t *testing.T) { + + args := []string{ + "-module", "my_module", + "", + } + + text, code := runTestRemoteOutputRequest(args, remoteStateResponseText) + + if code != 0 { + t.Fatalf("bad: \n%s", text) + } + + expectedOutput := strings.Split("\n", "blah = tastatur\nbaz = qux\n") + sort.Strings(expectedOutput) + + output := strings.Split("\n", text) + sort.Strings(output) + if reflect.DeepEqual(output, expectedOutput) != true { + t.Fatalf("Expected output: %#v\ngiven: %#v", expectedOutput, output) + } +} + +func TestRemoteOutput_missingModule(t *testing.T) { + args := []string{ + "-module", "not_existing_module", + "blah", + } + + if text, code := runTestRemoteOutputRequest(args, remoteStateResponseText); code != 1 { + t.Fatalf("bad: \n%s", text) + } +} + +func TestRemoteOutput_badVar(t *testing.T) { + args := []string{ + "bar", + } + + if text, code := runTestRemoteOutputRequest(args, remoteStateResponseText); code != 1 { + t.Fatalf("bad: \n%s", text) + } +} + +func TestRemoteOutput_manyArgs(t *testing.T) { + args := []string{ + "bad", + "bad", + } + + if text, code := runTestRemoteOutputRequest(args, remoteStateResponseText); code != 1 { + t.Fatalf("bad: \n%s", text) + } +} + +func TestRemoteOutput_noArgs(t *testing.T) { + ui := new(cli.MockUi) + c := &RemoteOutputCommand{ + 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 TestRemoteOutput_noState(t *testing.T) { + args := []string{ + "foo", + } + if text, code := runTestRemoteOutputRequest(args, remoteStateResponseTextNoState); code != 1 { + t.Fatalf("bad: \n%s", text) + } +} + +func TestRemoteOutput_noVars(t *testing.T) { + args := []string{ + "bar", + } + if text, code := runTestRemoteOutputRequest(args, remoteStateResponseTextNoVars); code != 1 { + t.Fatalf("bad: \n%s", text) + } +} diff --git a/website/source/docs/commands/index.html.markdown b/website/source/docs/commands/index.html.markdown index b02bef75d3ea..7ffea1974966 100644 --- a/website/source/docs/commands/index.html.markdown +++ b/website/source/docs/commands/index.html.markdown @@ -32,7 +32,7 @@ Available commands are: output Read an output from a state file plan Generate and show an execution plan refresh Update local state file against real resources - remote Configure remote state storage + remote Configure remote state storage, or read a remote state show Inspect Terraform state or plan taint Manually mark a resource for recreation validate Validates the Terraform files diff --git a/website/source/docs/commands/remote-output.html.markdown b/website/source/docs/commands/remote-output.html.markdown new file mode 100644 index 000000000000..292ff261d9d6 --- /dev/null +++ b/website/source/docs/commands/remote-output.html.markdown @@ -0,0 +1,40 @@ +--- +layout: "docs" +page_title: "Command: remote config" +sidebar_current: "docs-commands-remote-config" +description: |- + The `terraform remote output` command is used to read an output + variable from Terraform remote state. This command does not read + or alter your existing configruation, and can be used without + any remote state configured. +--- + +# Command: remote output + +The `terraform remote output` command is used to read an output variable from +Terraform remote state. This command does not read or alter your existing +configruation, and can be used without any remote state configured. + +## Usage + +Usage: `terraform remote output [options] [NAME]` + +Usage of the command is very similar to the +[`terraform output`](/docs/commands/output.html) and +[`terraform remote config`](/docs/commands/remote-config.html) +commands. + +If `NAME` is supplied, only that output is returned. + +The command-line flags are all optional. The list of available flags are: + +* `-remote-backend=Atlas` - Specifies the type of remote backend. See + `terraform remote config -help` for a list of supported backends. Defaults + to `Atlas`. + +* `-remote-config="k=v"` - Specifies configuration for the remote storage + backend. This can be specified multiple times. + +* `-no-color` - If specified, output won't contain any color. + +* `-module=name` - If specified, returns the outputs for a specific module. diff --git a/website/source/docs/commands/remote.html.markdown b/website/source/docs/commands/remote.html.markdown index 22d341891222..4ae87e7fec9e 100644 --- a/website/source/docs/commands/remote.html.markdown +++ b/website/source/docs/commands/remote.html.markdown @@ -10,7 +10,7 @@ description: |- # Command: remote -The `terraform remote` command is used to configure all aspects of +The `terraform remote` command is used to manage all aspects of remote state storage. When remote state storage is enabled, Terraform will automatically fetch the latest state from the remote server when necessary and if any updates are made, the newest state @@ -18,6 +18,10 @@ is persisted back to the remote server. In this mode, users do not need to durably store the state using version control or shared storage. +Additionally, the `terraform remote output` command allows one to read from +*any* Terraform remote state, even one that Terraform is not currently +configured for. + ## Usage Usage: `terraform remote SUBCOMMAND [options]` @@ -27,6 +31,8 @@ subcommands. The subcommands available are: * [config](/docs/commands/remote-config.html) - Configure the remote storage, including enabling/disabling it. + * [output](/docs/commands/remote-output.html) - Reads any remote state, + even one not currently configured. * [pull](/docs/commands/remote-pull.html) - Sync the remote storage to the local storage (download). * [push](/docs/commands/remote-push.html) - Sync the local storage to