diff --git a/command/output.go b/command/output.go index ec27a70ae..f1d446a3f 100644 --- a/command/output.go +++ b/command/output.go @@ -5,6 +5,8 @@ import ( "fmt" "strings" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" ctyjson "github.com/zclconf/go-cty/cty/json" "github.com/hashicorp/terraform/addrs" @@ -17,14 +19,19 @@ import ( // from a Terraform state and prints it. type OutputCommand struct { Meta + + // Unit tests may set rawPrint to capture the output from the -raw + // option, which would normally go to stdout directly. + rawPrint func(string) } func (c *OutputCommand) Run(args []string) int { args = c.Meta.process(args) var module, statePath string - var jsonOutput bool + var jsonOutput, rawOutput bool cmdFlags := c.Meta.defaultFlagSet("output") cmdFlags.BoolVar(&jsonOutput, "json", false, "json") + cmdFlags.BoolVar(&rawOutput, "raw", false, "raw") cmdFlags.StringVar(&statePath, "state", "", "path") cmdFlags.StringVar(&module, "module", "", "module") cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } @@ -42,6 +49,18 @@ func (c *OutputCommand) Run(args []string) int { return 1 } + if jsonOutput && rawOutput { + c.Ui.Error("The -raw and -json options are mutually-exclusive.\n") + cmdFlags.Usage() + return 1 + } + + if rawOutput && len(args) == 0 { + c.Ui.Error("You must give the name of a single output value when using the -raw option.\n") + cmdFlags.Usage() + return 1 + } + name := "" if len(args) > 0 { name = args[0] @@ -187,14 +206,65 @@ func (c *OutputCommand) Run(args []string) int { } v := os.Value - if jsonOutput { + switch { + case jsonOutput: jsonOutput, err := ctyjson.Marshal(v, v.Type()) if err != nil { return 1 } c.Ui.Output(string(jsonOutput)) - } else { + case rawOutput: + strV, err := convert.Convert(v, cty.String) + if err != nil { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unsupported value for raw output", + fmt.Sprintf( + "The -raw option only supports strings, numbers, and boolean values, but output value %q is %s.\n\nUse the -json option for machine-readable representations of output values that have complex types.", + name, v.Type().FriendlyName(), + ), + )) + c.showDiagnostics(diags) + return 1 + } + if strV.IsNull() { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unsupported value for raw output", + fmt.Sprintf( + "The value for output value %q is null, so -raw mode cannot print it.", + name, + ), + )) + c.showDiagnostics(diags) + return 1 + } + if !strV.IsKnown() { + // Since we're working with values from the state it would be very + // odd to end up in here, but we'll handle it anyway to avoid a + // panic in case our rules somehow change in future. + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Unsupported value for raw output", + fmt.Sprintf( + "The value for output value %q won't be known until after a successful terraform apply, so -raw mode cannot print it.", + name, + ), + )) + c.showDiagnostics(diags) + return 1 + } + // If we get out here then we should have a valid string to print. + // We're writing it directly to the output here so that a shell caller + // will get exactly the value and no extra whitespace. + str := strV.AsString() + if c.rawPrint != nil { + c.rawPrint(str) + } else { + fmt.Print(str) + } + default: result := repl.FormatValue(v, 0) c.Ui.Output(result) } @@ -219,8 +289,12 @@ Options: -no-color If specified, output won't contain any color. -json If specified, machine readable output will be - printed in JSON format + printed in JSON format. + -raw For value types that can be automatically + converted to a string, will print the raw + string directly, rather than a human-oriented + representation of the value. ` return strings.TrimSpace(helpText) } diff --git a/command/output_test.go b/command/output_test.go index 4ca121aa1..8b2edc3a6 100644 --- a/command/output_test.go +++ b/command/output_test.go @@ -130,6 +130,92 @@ func TestOutput_json(t *testing.T) { } } +func TestOutput_raw(t *testing.T) { + originalState := states.BuildState(func(s *states.SyncState) { + s.SetOutputValue( + addrs.OutputValue{Name: "str"}.Absolute(addrs.RootModuleInstance), + cty.StringVal("bar"), + false, + ) + s.SetOutputValue( + addrs.OutputValue{Name: "multistr"}.Absolute(addrs.RootModuleInstance), + cty.StringVal("bar\nbaz"), + false, + ) + s.SetOutputValue( + addrs.OutputValue{Name: "num"}.Absolute(addrs.RootModuleInstance), + cty.NumberIntVal(2), + false, + ) + s.SetOutputValue( + addrs.OutputValue{Name: "bool"}.Absolute(addrs.RootModuleInstance), + cty.True, + false, + ) + s.SetOutputValue( + addrs.OutputValue{Name: "obj"}.Absolute(addrs.RootModuleInstance), + cty.EmptyObjectVal, + false, + ) + s.SetOutputValue( + addrs.OutputValue{Name: "null"}.Absolute(addrs.RootModuleInstance), + cty.NullVal(cty.String), + false, + ) + }) + + statePath := testStateFile(t, originalState) + + tests := map[string]struct { + WantOutput string + WantErr bool + }{ + "str": {WantOutput: "bar"}, + "multistr": {WantOutput: "bar\nbaz"}, + "num": {WantOutput: "2"}, + "bool": {WantOutput: "true"}, + "obj": {WantErr: true}, + "null": {WantErr: true}, + } + + for name, test := range tests { + t.Run(name, func(t *testing.T) { + var printed string + ui := cli.NewMockUi() + c := &OutputCommand{ + Meta: Meta{ + testingOverrides: metaOverridesForProvider(testProvider()), + Ui: ui, + }, + rawPrint: func(s string) { + printed = s + }, + } + args := []string{ + "-state", statePath, + "-raw", + name, + } + code := c.Run(args) + + if code != 0 { + if !test.WantErr { + t.Errorf("unexpected failure\n%s", ui.ErrorWriter.String()) + } + return + } + + if test.WantErr { + t.Fatalf("succeeded, but want error") + } + + if got, want := printed, test.WantOutput; got != want { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + }) + } +} + func TestOutput_emptyOutputs(t *testing.T) { originalState := states.NewState() statePath := testStateFile(t, originalState) diff --git a/website/docs/commands/output.html.markdown b/website/docs/commands/output.html.markdown index 33b5a8aaa..8d58e6515 100644 --- a/website/docs/commands/output.html.markdown +++ b/website/docs/commands/output.html.markdown @@ -24,6 +24,11 @@ The command-line flags are all optional. The list of available flags are: * `-json` - If specified, the outputs are formatted as a JSON object, with a key per output. If `NAME` is specified, only the output specified will be returned. This can be piped into tools such as `jq` for further processing. +* `-raw` - If specified, Terraform will convert the specified output value to a + string and print that string directly to the output, without any special + formatting. This can be convenient when working with shell scripts, but + it only supports string, number, and boolean values. Use `-json` instead + for processing complex data types. * `-no-color` - If specified, output won't contain any color. * `-state=path` - Path to the state file. Defaults to "terraform.tfstate". Ignored when [remote state](/docs/state/remote.html) is used. @@ -88,21 +93,31 @@ instance_ips = [ ## Use in automation The `terraform output` command by default displays in a human-readable format, -which can change over time to improve clarity. For use in automation, use -`-json` to output the stable JSON format. You can parse the output using a JSON -command-line parser such as [jq](https://stedolan.github.io/jq/). +which can change over time to improve clarity. -For string outputs, you can remove quotes using `jq -r`: +For scripting and automation, use `-json` to produce the stable JSON format. +You can parse the output using a JSON command-line parser such as +[jq](https://stedolan.github.io/jq/): ```shellsession -$ terraform output -json lb_address | jq -r . +$ terraform output -json instance_ips | jq -r '.[0]' +54.43.114.12 +``` + +For the common case of directly using a string value in a shell script, you +can use `-raw` instead, which will print the string directly with no extra +escaping or whitespace. + +```shellsession +$ terraform output -raw lb_address my-app-alb-1657023003.us-east-1.elb.amazonaws.com ``` -To query for a particular value in a list, use `jq` with an index filter. For -example, to query for the first instance's IP address: +The `-raw` option works only with values that Terraform can automatically +convert to strings. Use `-json` instead, possibly combined with `jq`, to +work with complex-typed values such as objects. -```shellsession -$ terraform output -json instance_ips | jq '.[0]' -"54.43.114.12" -``` +Terraform strings are sequences of Unicode characters rather than raw bytes, +so the `-raw` output will be UTF-8 encoded when it contains non-ASCII +characters. If you need a different character encoding, use a separate command +such as `iconv` to transcode Terraform's raw output.