From 1af38bbf61b33decea1243de6ded99ec7228762a Mon Sep 17 00:00:00 2001 From: Chris Marchesi Date: Sat, 14 May 2016 19:43:29 -0700 Subject: [PATCH] WIP: Moving remote state output support to "terraform remote output" --- command/apply.go | 60 +------- command/output.go | 198 +++------------------------ command/output_helper.go | 214 +++++++++++++++++++++++++++++ command/output_helper_test.go | 230 +++++++++++++++++++++++++++++++ command/output_test.go | 82 ----------- command/refresh.go | 7 +- command/remote_output.go | 119 ++++++++++++++++ command/remote_output_test.go | 251 ++++++++++++++++++++++++++++++++++ 8 files changed, 837 insertions(+), 324 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 diff --git a/command/apply.go b/command/apply.go index 5598d5c355df..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.(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 bde41a7103e7..0a81265ed948 100644 --- a/command/output.go +++ b/command/output.go @@ -1,15 +1,9 @@ package command import ( - "bytes" "flag" "fmt" - "sort" - "strconv" "strings" - - "github.com/hashicorp/terraform/state/remote" - "github.com/hashicorp/terraform/terraform" ) // OutputCommand is a Command implementation that reads an output @@ -36,182 +30,37 @@ func (c *OutputCommand) Run(args []string) int { 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] - } - - if module == "" { - module = "root" - } else { - module = "root." + module - } - - // Get the proper module we want to get outputs for - modPath := strings.Split(module, ".") - - var state *terraform.State - if remoteState == true { - client, err := remote.NewClient(remoteBackend, backendConfig) - if err != nil { - c.Ui.Error(fmt.Sprintf("Error reading remote state: %s", err)) - return 1 - } - - remoteState := &remote.State{Client: client} - if err := remoteState.RefreshState(); err != nil { - c.Ui.Error(fmt.Sprintf("Error reading remote state: %s", err)) - return 1 - } - state = remoteState.State() - } else { - - stateStore, err := c.Meta.State() - if err != nil { - c.Ui.Error(fmt.Sprintf("Error reading state: %s", err)) - return 1 - } - 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)) + stateStore, err := c.Meta.State() + if err != nil { + c.Ui.Error(fmt.Sprintf("Error reading state: %s", err)) 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.")) + mod, err := moduleFromState(stateStore.State(), module) + if err != nil { + c.Ui.Error(err.Error()) 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.")) - return 1 - } - - switch output := v.(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: - panic(fmt.Errorf("Unknown output type: %T", output)) - } - - 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 != "" { - outputBuf.WriteString(fmt.Sprintf("\n%s]", indent)) - } - - 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 != "" { - outputBuf.WriteString(fmt.Sprintf("\n%s}", indent)) - } + c.Ui.Output(out) - return strings.TrimPrefix(outputBuf.String(), "\n") + return 0 } func (c *OutputCommand) Help() string { @@ -226,19 +75,6 @@ Options: -state=path Path to the state file to read. Defaults to "terraform.tfstate". - -remote=false Allows the fetching of outputs from remote state, - independent of any existing Terraform configuration. - If this is specified, -path is ignored, and remote - state path should be specified via -remote-config - as required. - - -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 diff --git a/command/output_helper.go b/command/output_helper.go new file mode 100644 index 000000000000..a829e3b468ec --- /dev/null +++ b/command/output_helper.go @@ -0,0 +1,214 @@ +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.(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 != "" { + outputBuf.WriteString(fmt.Sprintf("\n%s]", indent)) + } + + 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 != "" { + outputBuf.WriteString(fmt.Sprintf("\n%s}", indent)) + } + + 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.(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..606b08c08405 --- /dev/null +++ b/command/output_helper_test.go @@ -0,0 +1,230 @@ +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]interface{}{ + "foo": "bar", + }, + }, + &terraform.ModuleState{ + Path: []string{"root", "my_module"}, + Outputs: map[string]interface{}{ + "blah": "tastatur", + }, + }, + }, + } +} + +// testModuleStateConfig provides a mock ModuleState for testing. +func testModuleStateConfig() *terraform.ModuleState { + return &terraform.ModuleState{ + Path: []string{"root", "my_module"}, + Outputs: map[string]interface{}{ + "foo": "bar", + "baz": "qux", + "listoutput": []interface{}{"one", "two"}, + "mapoutput": map[string]interface{}{ + "key": "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, + }, + } +} + +const testOutputAsStringExpected = ` +foo = bar +baz = +listoutput = [ + one + two +] +mapoutput = { + key = value +} +` + +func TestParseOutputNameIndex(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 TestParseOutputNameIndex_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 TestParseOutputNameIndex_tooManyArgs(t *testing.T) { + name, index, err := parseOutputNameIndex([]string{"foo", "2", "bar"}) + + if err == nil { + t.Fatalf("bad: %s, %s", name, index) + } +} + +func TestModuleFromState(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 TestModuleFromState_badModule(t *testing.T) { + originalState := testStateConfig() + mod, err := moduleFromState(originalState, "wrong_module") + + if err == nil { + t.Fatalf("expected error, got %v", mod) + } +} + +func TestModuleFromState_emptyState(t *testing.T) { + originalState := &terraform.State{} + mod, err := moduleFromState(originalState, "") + + if err == nil { + t.Fatalf("expected error, got %v", mod) + } +} + +func TestSingleOutputAsString(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 TestSingleOutputAsString_notFound(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "nonexistent", "0") + if err == nil { + t.Fatalf("expected error, got %v", out) + } +} + +func TestSingleOutputAsString_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 TestSingleOutputAsString_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 TestSingleOutputAsString_listBadIndex(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "listoutput", "nope") + if err == nil { + t.Fatalf("expected error, got %v", out) + } +} + +func TestSingleOutputAsString_listOutOfRange(t *testing.T) { + mod := testModuleStateConfig() + + out, err := singleOutputAsString(mod, "listoutput", "100") + if err == nil { + t.Fatalf("expected error, got %v", out) + } +} + +func TestAllOutputsAsString(t *testing.T) { + mod := testModuleStateConfig() + schema := testOutputSchemaConfig() + + out := allOutputsAsString(mod, schema, false) + + expected := testOutputAsStringExpected + + if out != expected { + t.Fatalf("expected out to be %s, got %s", expected, out) + } +} diff --git a/command/output_test.go b/command/output_test.go index dde2a02aa797..9c79f82ca1bf 100644 --- a/command/output_test.go +++ b/command/output_test.go @@ -2,12 +2,8 @@ package command import ( "io/ioutil" - "net/http" - "net/http/httptest" "os" "path/filepath" - "reflect" - "sort" "strings" "testing" @@ -341,81 +337,3 @@ func TestOutput_stateDefault(t *testing.T) { t.Fatalf("bad: %#v", actual) } } - -// remoteStateResponseText defines mock remote state response text. -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": {} - }] -} -` - -// remoteStateOutputExpected defines the expected outputs. -const remoteStateOutputExpected = "foo = bar\nbaz = qux\n" - -// 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() *httptest.Server { - return newRemoteStateHTTPTestServer(func(w http.ResponseWriter, r *http.Request) { - w.Header().Add("Content-Type", "application/json") - http.Error(w, remoteStateResponseText, http.StatusOK) - }) -} - -// TestOutput_remoteState tests remote state outputs. -func TestOutput_remoteState(t *testing.T) { - ts := httpRemoteStateTestServer() - defer ts.Close() - - ui := new(cli.MockUi) - c := &OutputCommand{ - Meta: Meta{ - ContextOpts: testCtxConfig(testProvider()), - Ui: ui, - }, - } - - args := []string{ - "-remote=true", - "-remote-backend=http", - "-remote-config=address=" + ts.URL, - "", - } - - if code := c.Run(args); code != 0 { - t.Fatalf("bad: \n%s", ui.ErrorWriter.String()) - } - - // Our output needs to be sorted here, remote state returns unordered. - expectedOutput := strings.Split("\n", remoteStateOutputExpected) - 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) - } -} 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_output.go b/command/remote_output.go new file mode 100644 index 000000000000..4cd348849ce8 --- /dev/null +++ b/command/remote_output.go @@ -0,0 +1,119 @@ +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: + + -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. Only valid when a specific output + is provided. + +` + 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) + } +}