Merge pull request #27738 from hashicorp/alisdair/command-views

cli: Add initial command views abstraction
This commit is contained in:
Alisdair McDiarmid 2021-02-12 10:14:54 -05:00 committed by GitHub
commit 6f58037d6a
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 1102 additions and 391 deletions

View File

@ -1,15 +1,13 @@
package command
import (
"bytes"
"fmt"
"sort"
"strings"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/plans/planfile"
"github.com/hashicorp/terraform/repl"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/tfdiags"
)
@ -230,9 +228,12 @@ func (c *ApplyCommand) Run(args []string) int {
c.Meta.stateOutPath)))
}
if !c.Destroy {
if outputs := outputsAsString(op.State, true); outputs != "" {
c.Ui.Output(c.Colorize().Color(outputs))
if !c.Destroy && op.State != nil {
outputValues := op.State.RootModule().OutputValues
if len(outputValues) > 0 {
c.Ui.Output(c.Colorize().Color("[reset][bold][green]\nOutputs:\n\n"))
view := views.NewOutput(arguments.ViewHuman, c.View)
view.Output("", outputValues)
}
}
@ -366,45 +367,6 @@ Options:
return strings.TrimSpace(helpText)
}
func outputsAsString(state *states.State, includeHeader bool) string {
if state == nil {
return ""
}
ms := state.RootModule()
outputs := ms.OutputValues
outputBuf := new(bytes.Buffer)
if len(outputs) > 0 {
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 {
v := outputs[k]
if v.Sensitive {
outputBuf.WriteString(fmt.Sprintf("%s = <sensitive>\n", k))
continue
}
result := repl.FormatValue(v.Value, 0)
outputBuf.WriteString(fmt.Sprintf("%s = %s\n", k, result))
}
}
return strings.TrimSpace(outputBuf.String())
}
const outputInterrupt = `Interrupt received.
Please wait for Terraform to exit or data loss may occur.
Gracefully shutting down...`

View File

@ -58,11 +58,13 @@ func TestApply_destroy(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -157,11 +159,13 @@ func TestApply_destroyApproveNo(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -206,11 +210,13 @@ func TestApply_destroyApproveYes(t *testing.T) {
defaultInputWriter = new(bytes.Buffer)
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -271,11 +277,13 @@ func TestApply_destroyLockedState(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -306,11 +314,13 @@ func TestApply_destroyPlan(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -337,11 +347,13 @@ func TestApply_destroyPath(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -430,11 +442,13 @@ func TestApply_destroyTargetedDependencies(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -579,11 +593,13 @@ func TestApply_destroyTargeted(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Destroy: true,
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}

View File

@ -38,10 +38,12 @@ func TestApply(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -73,10 +75,12 @@ func TestApply_path(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -112,10 +116,12 @@ func TestApply_approveNo(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -154,10 +160,12 @@ func TestApply_approveYes(t *testing.T) {
defaultInputWriter = new(bytes.Buffer)
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -196,10 +204,12 @@ func TestApply_lockedState(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -240,10 +250,12 @@ func TestApply_lockedStateWait(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -336,10 +348,12 @@ func TestApply_parallelism(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: testingOverrides,
Ui: ui,
View: view,
},
}
@ -364,10 +378,12 @@ func TestApply_configInvalid(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -401,10 +417,12 @@ func TestApply_defaultState(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -442,10 +460,12 @@ func TestApply_error(t *testing.T) {
p := testProvider()
ui := cli.NewMockUi()
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -534,10 +554,12 @@ func TestApply_input(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -579,10 +601,12 @@ func TestApply_inputPartial(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -616,10 +640,12 @@ func TestApply_noArgs(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -655,10 +681,12 @@ func TestApply_plan(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -687,10 +715,12 @@ func TestApply_plan_backup(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -719,10 +749,12 @@ func TestApply_plan_noBackup(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -797,10 +829,12 @@ func TestApply_plan_remoteState(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -844,10 +878,12 @@ func TestApply_planWithVarFile(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -875,10 +911,12 @@ func TestApply_planVars(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -943,10 +981,12 @@ func TestApply_refresh(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -995,10 +1035,12 @@ func TestApply_shutdown(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
ShutdownCh: shutdownCh,
},
}
@ -1107,10 +1149,12 @@ func TestApply_state(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1170,10 +1214,12 @@ func TestApply_stateNoExist(t *testing.T) {
p := applyFixtureProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1194,10 +1240,12 @@ func TestApply_sensitiveOutput(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, done := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1212,7 +1260,7 @@ func TestApply_sensitiveOutput(t *testing.T) {
t.Fatalf("bad: \n%s", ui.OutputWriter.String())
}
output := ui.OutputWriter.String()
output := done(t).Stdout()
if !strings.Contains(output, "notsensitive = \"Hello world\"") {
t.Fatalf("bad: output should contain 'notsensitive' output\n%s", output)
}
@ -1232,10 +1280,12 @@ func TestApply_vars(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1293,10 +1343,12 @@ func TestApply_varFile(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1354,10 +1406,12 @@ func TestApply_varFileDefault(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1414,10 +1468,12 @@ func TestApply_varFileDefaultJSON(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1493,10 +1549,12 @@ func TestApply_backup(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1552,10 +1610,12 @@ func TestApply_disableBackup(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1623,10 +1683,12 @@ func TestApply_terraformEnv(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1658,8 +1720,9 @@ func TestApply_terraformEnvNonDefault(t *testing.T) {
// Create new env
{
ui := new(cli.MockUi)
view, _ := testView(t)
newCmd := &WorkspaceNewCommand{}
newCmd.Meta = Meta{Ui: ui}
newCmd.Meta = Meta{Ui: ui, View: view}
if code := newCmd.Run([]string{"test"}); code != 0 {
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter)
}
@ -1669,8 +1732,9 @@ func TestApply_terraformEnvNonDefault(t *testing.T) {
{
args := []string{"test"}
ui := new(cli.MockUi)
view, _ := testView(t)
selCmd := &WorkspaceSelectCommand{}
selCmd.Meta = Meta{Ui: ui}
selCmd.Meta = Meta{Ui: ui, View: view}
if code := selCmd.Run(args); code != 0 {
t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter)
}
@ -1678,10 +1742,12 @@ func TestApply_terraformEnvNonDefault(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1728,10 +1794,12 @@ func TestApply_targeted(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -1763,9 +1831,11 @@ func TestApply_targetFlagsDiags(t *testing.T) {
defer testChdir(t, td)()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &ApplyCommand{
Meta: Meta{
Ui: ui,
Ui: ui,
View: view,
},
}

View File

@ -0,0 +1,16 @@
package arguments
import (
"flag"
"io/ioutil"
)
// defaultFlagSet creates a FlagSet with the common settings to override
// the flag package's noisy defaults.
func defaultFlagSet(name string) *flag.FlagSet {
f := flag.NewFlagSet(name, flag.ContinueOnError)
f.SetOutput(ioutil.Discard)
f.Usage = func() {}
return f
}

View File

@ -0,0 +1,88 @@
package arguments
import (
"github.com/hashicorp/terraform/tfdiags"
)
// Output represents the command-line arguments for the output command.
type Output struct {
// Name identifies which root module output to show. If empty, show all
// outputs.
Name string
// StatePath is an optional path to a state file, from which outputs will
// be loaded.
StatePath string
// ViewType specifies which output format to use: human, JSON, or "raw".
ViewType ViewType
}
// ParseOutput processes CLI arguments, returning an Output value and errors.
// If errors are encountered, an Output value is still returned representing
// the best effort interpretation of the arguments.
func ParseOutput(args []string) (*Output, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
output := &Output{}
var jsonOutput, rawOutput bool
var statePath string
cmdFlags := defaultFlagSet("output")
cmdFlags.BoolVar(&jsonOutput, "json", false, "json")
cmdFlags.BoolVar(&rawOutput, "raw", false, "raw")
cmdFlags.StringVar(&statePath, "state", "", "path")
if err := cmdFlags.Parse(args); err != nil {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Failed to parse command-line flags",
err.Error(),
))
}
args = cmdFlags.Args()
if len(args) > 1 {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Unexpected argument",
"The output command expects exactly one argument with the name of an output variable or no arguments to show all outputs.",
))
}
if jsonOutput && rawOutput {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Invalid output format",
"The -raw and -json options are mutually-exclusive.",
))
// Since the desired output format is unknowable, fall back to default
jsonOutput = false
rawOutput = false
}
output.StatePath = statePath
if len(args) > 0 {
output.Name = args[0]
}
if rawOutput && output.Name == "" {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Error,
"Output name required",
"You must give the name of a single output value when using the -raw option.",
))
}
switch {
case jsonOutput:
output.ViewType = ViewJSON
case rawOutput:
output.ViewType = ViewRaw
default:
output.ViewType = ViewHuman
}
return output, diags
}

View File

@ -0,0 +1,142 @@
package arguments
import (
"reflect"
"testing"
"github.com/davecgh/go-spew/spew"
"github.com/hashicorp/terraform/tfdiags"
)
func TestParseOutput_valid(t *testing.T) {
testCases := map[string]struct {
args []string
want *Output
}{
"defaults": {
nil,
&Output{
Name: "",
ViewType: ViewHuman,
StatePath: "",
},
},
"json": {
[]string{"-json"},
&Output{
Name: "",
ViewType: ViewJSON,
StatePath: "",
},
},
"raw": {
[]string{"-raw", "foo"},
&Output{
Name: "foo",
ViewType: ViewRaw,
StatePath: "",
},
},
"state": {
[]string{"-state=foobar.tfstate", "-raw", "foo"},
&Output{
Name: "foo",
ViewType: ViewRaw,
StatePath: "foobar.tfstate",
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, diags := ParseOutput(tc.args)
if len(diags) > 0 {
t.Fatalf("unexpected diags: %v", diags)
}
if *got != *tc.want {
t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want)
}
})
}
}
func TestParseOutput_invalid(t *testing.T) {
testCases := map[string]struct {
args []string
want *Output
wantDiags tfdiags.Diagnostics
}{
"unknown flag": {
[]string{"-boop"},
&Output{
Name: "",
ViewType: ViewHuman,
StatePath: "",
},
tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Failed to parse command-line flags",
"flag provided but not defined: -boop",
),
},
},
"json and raw specified": {
[]string{"-json", "-raw"},
&Output{
Name: "",
ViewType: ViewHuman,
StatePath: "",
},
tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Invalid output format",
"The -raw and -json options are mutually-exclusive.",
),
},
},
"raw with no name": {
[]string{"-raw"},
&Output{
Name: "",
ViewType: ViewRaw,
StatePath: "",
},
tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Output name required",
"You must give the name of a single output value when using the -raw option.",
),
},
},
"too many arguments": {
[]string{"-raw", "-state=foo.tfstate", "bar", "baz"},
&Output{
Name: "bar",
ViewType: ViewRaw,
StatePath: "foo.tfstate",
},
tfdiags.Diagnostics{
tfdiags.Sourceless(
tfdiags.Error,
"Unexpected argument",
"The output command expects exactly one argument with the name of an output variable or no arguments to show all outputs.",
),
},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, gotDiags := ParseOutput(tc.args)
if *got != *tc.want {
t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want)
}
if !reflect.DeepEqual(gotDiags, tc.wantDiags) {
t.Errorf("wrong result\ngot: %s\nwant: %s", spew.Sdump(gotDiags), spew.Sdump(tc.wantDiags))
}
})
}
}

View File

@ -0,0 +1,13 @@
package arguments
// ViewType represents which view layer to use for a given command. Not all
// commands will support all view types, and validation that the type is
// supported should happen in the view constructor.
type ViewType rune
const (
ViewNone ViewType = 0
ViewHuman ViewType = 'H'
ViewJSON ViewType = 'J'
ViewRaw ViewType = 'R'
)

43
command/arguments/view.go Normal file
View File

@ -0,0 +1,43 @@
package arguments
// View represents the global command-line arguments which configure the view.
type View struct {
// NoColor is used to disable the use of terminal color codes in all
// output.
NoColor bool
// CompactWarnings is used to coalesce duplicate warnings, to reduce the
// level of noise when multiple instances of the same warning are raised
// for a configuration.
CompactWarnings bool
}
// ParseView processes CLI arguments, returning a View value and a
// possibly-modified slice of arguments. If any of the supported flags are
// found, they will be removed from the slice.
func ParseView(args []string) (*View, []string) {
common := &View{}
// Keep track of the length of the returned slice. When we find an
// argument we support, i will not be incremented.
i := 0
for _, v := range args {
switch v {
case "-no-color":
common.NoColor = true
case "-compact-warnings":
common.CompactWarnings = true
default:
// Unsupported argument: move left to the current position, and
// increment the index.
args[i] = v
i++
}
}
// Reduce the slice to the number of unsupported arguments. Any remaining
// to the right of i have already been moved left.
args = args[:i]
return common, args
}

View File

@ -0,0 +1,62 @@
package arguments
import (
"testing"
"github.com/google/go-cmp/cmp"
)
func TestParseView(t *testing.T) {
testCases := map[string]struct {
args []string
want *View
wantArgs []string
}{
"nil": {
nil,
&View{NoColor: false, CompactWarnings: false},
nil,
},
"empty": {
[]string{},
&View{NoColor: false, CompactWarnings: false},
[]string{},
},
"none matching": {
[]string{"-foo", "bar", "-baz"},
&View{NoColor: false, CompactWarnings: false},
[]string{"-foo", "bar", "-baz"},
},
"no-color": {
[]string{"-foo", "-no-color", "-baz"},
&View{NoColor: true, CompactWarnings: false},
[]string{"-foo", "-baz"},
},
"compact-warnings": {
[]string{"-foo", "-compact-warnings", "-baz"},
&View{NoColor: false, CompactWarnings: true},
[]string{"-foo", "-baz"},
},
"both": {
[]string{"-foo", "-no-color", "-compact-warnings", "-baz"},
&View{NoColor: true, CompactWarnings: true},
[]string{"-foo", "-baz"},
},
"both, resulting in empty args": {
[]string{"-no-color", "-compact-warnings"},
&View{NoColor: true, CompactWarnings: true},
[]string{},
},
}
for name, tc := range testCases {
t.Run(name, func(t *testing.T) {
got, gotArgs := ParseView(tc.args)
if *got != *tc.want {
t.Errorf("unexpected result\n got: %#v\nwant: %#v", got, tc.want)
}
if !cmp.Equal(gotArgs, tc.wantArgs) {
t.Errorf("unexpected args\n got: %#v\nwant: %#v", gotArgs, tc.wantArgs)
}
})
}
}

View File

@ -19,8 +19,10 @@ import (
svchost "github.com/hashicorp/terraform-svchost"
"github.com/hashicorp/terraform-svchost/disco"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/internal/getproviders"
"github.com/hashicorp/terraform/internal/initwd"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/registry"
"github.com/hashicorp/terraform/addrs"
@ -1028,3 +1030,8 @@ func fakeRegistryHandler(resp http.ResponseWriter, req *http.Request) {
resp.Write([]byte(`provider not found`))
}
}
func testView(t *testing.T) (*views.View, func(*testing.T) *terminal.TestOutput) {
streams, done := terminal.StreamsForTesting(t)
return views.NewView(streams), done
}

View File

@ -22,6 +22,7 @@ import (
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/backend/local"
"github.com/hashicorp/terraform/command/format"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/command/webbrowser"
"github.com/hashicorp/terraform/configs/configload"
"github.com/hashicorp/terraform/internal/getproviders"
@ -62,6 +63,8 @@ type Meta struct {
// do some default behavior instead if so, rather than panicking.
Streams *terminal.Streams
View *views.View
Color bool // True if output should be colored
GlobalPluginDirs []string // Additional paths to search for plugins
Ui cli.Ui // Ui for output
@ -499,6 +502,7 @@ func (m *Meta) contextOpts() (*terraform.ContextOpts, error) {
}
// defaultFlagSet creates a default flag set for commands.
// See also command/arguments/default.go
func (m *Meta) defaultFlagSet(n string) *flag.FlagSet {
f := flag.NewFlagSet(n, flag.ContinueOnError)
f.SetOutput(ioutil.Discard)
@ -581,9 +585,9 @@ func (m *Meta) parseTargetFlags() tfdiags.Diagnostics {
return diags
}
// process will process the meta-parameters out of the arguments. This
// process will process any -no-color entries out of the arguments. This
// will potentially modify the args in-place. It will return the resulting
// slice.
// slice, and update the Meta and Ui.
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

View File

@ -329,6 +329,9 @@ func (m *Meta) initConfigLoader() (*configload.Loader, error) {
return nil, err
}
m.configLoader = loader
if m.View != nil {
m.View.SetConfigSources(loader.Sources)
}
}
return m.configLoader, nil
}

View File

@ -1,15 +1,11 @@
package command
import (
"encoding/json"
"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/repl"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/tfdiags"
)
@ -18,64 +14,56 @@ 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 statePath string
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.Usage = func() { c.Ui.Error(c.Help()) }
if err := cmdFlags.Parse(args); err != nil {
c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
func (c *OutputCommand) Run(rawArgs []string) int {
// Parse and apply global view arguments
common, rawArgs := arguments.ParseView(rawArgs)
c.View.Configure(common)
// Parse and validate flags
args, diags := arguments.ParseOutput(rawArgs)
if diags.HasErrors() {
c.View.Diagnostics(diags)
c.View.HelpPrompt("output")
return 1
}
args = cmdFlags.Args()
if len(args) > 1 {
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()
view := views.NewOutput(args.ViewType, c.View)
// Fetch data from state
outputs, diags := c.Outputs(args.StatePath)
if diags.HasErrors() {
view.Diagnostics(diags)
return 1
}
if jsonOutput && rawOutput {
c.Ui.Error("The -raw and -json options are mutually-exclusive.\n")
cmdFlags.Usage()
// Render the view
viewDiags := view.Output(args.Name, outputs)
diags = diags.Append(viewDiags)
view.Diagnostics(diags)
if diags.HasErrors() {
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
}
return 0
}
name := ""
if len(args) > 0 {
name = args[0]
}
func (c *OutputCommand) Outputs(statePath string) (map[string]*states.OutputValue, tfdiags.Diagnostics) {
var diags tfdiags.Diagnostics
// Allow state path override
if statePath != "" {
c.Meta.statePath = statePath
}
var diags tfdiags.Diagnostics
// Load the backend
b, backendDiags := c.Backend(nil)
diags = diags.Append(backendDiags)
if backendDiags.HasErrors() {
c.showDiagnostics(diags)
return 1
if diags.HasErrors() {
return nil, diags
}
// This is a read-only command
@ -83,20 +71,20 @@ func (c *OutputCommand) Run(args []string) int {
env, err := c.Workspace()
if err != nil {
c.Ui.Error(fmt.Sprintf("Error selecting workspace: %s", err))
return 1
diags = diags.Append(fmt.Errorf("Error selecting workspace: %s", err))
return nil, diags
}
// Get the state
stateStore, err := b.StateMgr(env)
if err != nil {
c.Ui.Error(fmt.Sprintf("Failed to load state: %s", err))
return 1
diags = diags.Append(fmt.Errorf("Failed to load state: %s", err))
return nil, diags
}
if err := stateStore.RefreshState(); err != nil {
c.Ui.Error(fmt.Sprintf("Failed to load state: %s", err))
return 1
diags = diags.Append(fmt.Errorf("Failed to load state: %s", err))
return nil, diags
}
state := stateStore.State()
@ -104,147 +92,7 @@ func (c *OutputCommand) Run(args []string) int {
state = states.NewState()
}
mod := state.RootModule()
if !jsonOutput && (state.Empty() || len(mod.OutputValues) == 0) {
diags = diags.Append(tfdiags.Sourceless(
tfdiags.Warning,
"No outputs found",
"The state file either has no outputs defined, or all the defined "+
"outputs are empty. Please define an output in your configuration "+
"with the `output` keyword and run `terraform refresh` for it to "+
"become available. If you are using interpolation, please verify "+
"the interpolated value is not empty. You can use the "+
"`terraform console` command to assist.",
))
c.showDiagnostics(diags)
return 0
}
if name == "" {
if jsonOutput {
// Due to a historical accident, the switch from state version 2 to
// 3 caused our JSON output here to be the full metadata about the
// outputs rather than just the output values themselves as we'd
// show in the single value case. We must now maintain that behavior
// for compatibility, so this is an emulation of the JSON
// serialization of outputs used in state format version 3.
type OutputMeta struct {
Sensitive bool `json:"sensitive"`
Type json.RawMessage `json:"type"`
Value json.RawMessage `json:"value"`
}
outputs := map[string]OutputMeta{}
for n, os := range mod.OutputValues {
jsonVal, err := ctyjson.Marshal(os.Value, os.Value.Type())
if err != nil {
diags = diags.Append(err)
c.showDiagnostics(diags)
return 1
}
jsonType, err := ctyjson.MarshalType(os.Value.Type())
if err != nil {
diags = diags.Append(err)
c.showDiagnostics(diags)
return 1
}
outputs[n] = OutputMeta{
Sensitive: os.Sensitive,
Type: json.RawMessage(jsonType),
Value: json.RawMessage(jsonVal),
}
}
jsonOutputs, err := json.MarshalIndent(outputs, "", " ")
if err != nil {
diags = diags.Append(err)
c.showDiagnostics(diags)
return 1
}
c.Ui.Output(string(jsonOutputs))
return 0
} else {
c.Ui.Output(outputsAsString(state, false))
return 0
}
}
os, ok := mod.OutputValues[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
}
v := os.Value
switch {
case jsonOutput:
jsonOutput, err := ctyjson.Marshal(v, v.Type())
if err != nil {
return 1
}
c.Ui.Output(string(jsonOutput))
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)
}
return 0
return state.RootModule().OutputValues, nil
}
func (c *OutputCommand) Help() string {

View File

@ -6,7 +6,6 @@ import (
"strings"
"testing"
"github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/addrs"
@ -24,11 +23,11 @@ func TestOutput(t *testing.T) {
statePath := testStateFile(t, originalState)
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
@ -36,11 +35,13 @@ func TestOutput(t *testing.T) {
"-state", statePath,
"foo",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
actual := strings.TrimSpace(ui.OutputWriter.String())
actual := strings.TrimSpace(output.Stdout())
if actual != `"bar"` {
t.Fatalf("bad: %#v", actual)
}
@ -64,22 +65,24 @@ func TestOutput_nestedListAndMap(t *testing.T) {
})
statePath := testStateFile(t, originalState)
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
args := []string{
"-state", statePath,
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
actual := strings.TrimSpace(ui.OutputWriter.String())
actual := strings.TrimSpace(output.Stdout())
expected := strings.TrimSpace(`
foo = tolist([
tomap({
@ -107,11 +110,11 @@ func TestOutput_json(t *testing.T) {
statePath := testStateFile(t, originalState)
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
@ -119,123 +122,43 @@ func TestOutput_json(t *testing.T) {
"-state", statePath,
"-json",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
actual := strings.TrimSpace(ui.OutputWriter.String())
actual := strings.TrimSpace(output.Stdout())
expected := "{\n \"foo\": {\n \"sensitive\": false,\n \"type\": \"string\",\n \"value\": \"bar\"\n }\n}"
if actual != expected {
t.Fatalf("wrong output\ngot: %#v\nwant: %#v", actual, expected)
}
}
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)
p := testProvider()
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
args := []string{
"-no-color",
"-state", statePath,
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
if got, want := ui.ErrorWriter.String(), "Warning: No outputs found"; !strings.Contains(got, want) {
// Warning diagnostics should go to stdout
if got, want := output.Stdout(), "Warning: No outputs found"; !strings.Contains(got, want) {
t.Fatalf("bad output: expected to contain %q, got:\n%s", want, got)
}
}
@ -245,11 +168,11 @@ func TestOutput_jsonEmptyOutputs(t *testing.T) {
statePath := testStateFile(t, originalState)
p := testProvider()
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -257,11 +180,13 @@ func TestOutput_jsonEmptyOutputs(t *testing.T) {
"-state", statePath,
"-json",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
actual := strings.TrimSpace(ui.OutputWriter.String())
actual := strings.TrimSpace(output.Stdout())
expected := "{}"
if actual != expected {
t.Fatalf("bad:\n%#v\n%#v", expected, actual)
@ -278,11 +203,11 @@ func TestOutput_badVar(t *testing.T) {
})
statePath := testStateFile(t, originalState)
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
@ -290,8 +215,10 @@ func TestOutput_badVar(t *testing.T) {
"-state", statePath,
"bar",
}
if code := c.Run(args); code != 1 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("bad: \n%s", output.Stderr())
}
}
@ -310,11 +237,11 @@ func TestOutput_blank(t *testing.T) {
})
statePath := testStateFile(t, originalState)
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
@ -323,23 +250,24 @@ func TestOutput_blank(t *testing.T) {
"",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
expectedOutput := "foo = \"bar\"\nname = \"john-doe\"\n"
output := ui.OutputWriter.String()
if output != expectedOutput {
t.Fatalf("wrong output\ngot: %#v\nwant: %#v", output, expectedOutput)
if got := output.Stdout(); got != expectedOutput {
t.Fatalf("wrong output\ngot: %#v\nwant: %#v", got, expectedOutput)
}
}
func TestOutput_manyArgs(t *testing.T) {
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
@ -347,23 +275,27 @@ func TestOutput_manyArgs(t *testing.T) {
"bad",
"bad",
}
if code := c.Run(args); code != 1 {
t.Fatalf("bad: \n%s", ui.OutputWriter.String())
code := c.Run(args)
output := done(t)
if code != 1 {
t.Fatalf("bad: \n%s", output.Stdout())
}
}
func TestOutput_noArgs(t *testing.T) {
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
args := []string{}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.OutputWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stdout())
}
}
@ -371,11 +303,11 @@ func TestOutput_noState(t *testing.T) {
originalState := states.NewState()
statePath := testStateFile(t, originalState)
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
@ -383,8 +315,10 @@ func TestOutput_noState(t *testing.T) {
"-state", statePath,
"foo",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
}
@ -393,11 +327,11 @@ func TestOutput_noVars(t *testing.T) {
statePath := testStateFile(t, originalState)
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
@ -405,8 +339,10 @@ func TestOutput_noVars(t *testing.T) {
"-state", statePath,
"bar",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
}
@ -444,22 +380,24 @@ func TestOutput_stateDefault(t *testing.T) {
}
defer os.Chdir(cwd)
ui := new(cli.MockUi)
view, done := testView(t)
c := &OutputCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui,
View: view,
},
}
args := []string{
"foo",
}
if code := c.Run(args); code != 0 {
t.Fatalf("bad: \n%s", ui.ErrorWriter.String())
code := c.Run(args)
output := done(t)
if code != 0 {
t.Fatalf("bad: \n%s", output.Stderr())
}
actual := strings.TrimSpace(ui.OutputWriter.String())
actual := strings.TrimSpace(output.Stdout())
if actual != `"bar"` {
t.Fatalf("bad: %#v", actual)
}

View File

@ -5,6 +5,8 @@ import (
"strings"
"github.com/hashicorp/terraform/backend"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/tfdiags"
)
@ -100,8 +102,13 @@ func (c *RefreshCommand) Run(args []string) int {
return op.Result.ExitStatus()
}
if outputs := outputsAsString(op.State, true); outputs != "" {
c.Ui.Output(c.Colorize().Color(outputs))
if op.State != nil {
outputValues := op.State.RootModule().OutputValues
if len(outputValues) > 0 {
c.Ui.Output(c.Colorize().Color("[reset][bold][green]\nOutputs:\n\n"))
view := views.NewOutput(arguments.ViewHuman, c.View)
view.Output("", outputValues)
}
}
return op.Result.ExitStatus()

View File

@ -38,10 +38,12 @@ func TestRefresh(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -91,10 +93,12 @@ func TestRefresh_empty(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -133,10 +137,12 @@ func TestRefresh_lockedState(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -177,10 +183,12 @@ func TestRefresh_cwd(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -255,10 +263,12 @@ func TestRefresh_defaultState(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -323,10 +333,12 @@ func TestRefresh_outPath(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -382,10 +394,12 @@ func TestRefresh_var(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
p.GetSchemaResponse = refreshVarFixtureSchema()
@ -418,10 +432,12 @@ func TestRefresh_varFile(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
p.GetSchemaResponse = refreshVarFixtureSchema()
@ -459,10 +475,12 @@ func TestRefresh_varFileDefault(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
p.GetSchemaResponse = refreshVarFixtureSchema()
@ -505,10 +523,12 @@ func TestRefresh_varsUnset(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
p.GetSchemaResponse = &providers.GetSchemaResponse{
@ -568,10 +588,12 @@ func TestRefresh_backup(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -637,10 +659,12 @@ func TestRefresh_disableBackup(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -702,10 +726,12 @@ func TestRefresh_displaysOutputs(t *testing.T) {
p := testProvider()
ui := new(cli.MockUi)
view, done := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
p.GetSchemaResponse = &providers.GetSchemaResponse{
@ -730,7 +756,7 @@ func TestRefresh_displaysOutputs(t *testing.T) {
// Test that outputs were displayed
outputValue := "foo.example.com"
actual := ui.OutputWriter.String()
actual := done(t).Stdout()
if !strings.Contains(actual, outputValue) {
t.Fatalf("Expected:\n%s\n\nTo include: %q", actual, outputValue)
}
@ -765,10 +791,12 @@ func TestRefresh_targeted(t *testing.T) {
}
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
testingOverrides: metaOverridesForProvider(p),
Ui: ui,
View: view,
},
}
@ -803,9 +831,11 @@ func TestRefresh_targetFlagsDiags(t *testing.T) {
defer testChdir(t, td)()
ui := new(cli.MockUi)
view, _ := testView(t)
c := &RefreshCommand{
Meta: Meta{
Ui: ui,
Ui: ui,
View: view,
},
}

278
command/views/output.go Normal file
View File

@ -0,0 +1,278 @@
package views
import (
"bytes"
"encoding/json"
"fmt"
"sort"
"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/command/arguments"
"github.com/hashicorp/terraform/repl"
"github.com/hashicorp/terraform/states"
"github.com/hashicorp/terraform/tfdiags"
)
// The Output view renders either one or all outputs, depending on whether or
// not the name argument is empty.
type Output interface {
Output(name string, outputs map[string]*states.OutputValue) tfdiags.Diagnostics
Diagnostics(diags tfdiags.Diagnostics)
}
// NewOutput returns an initialized Output implementation for the given ViewType.
func NewOutput(vt arguments.ViewType, view *View) Output {
switch vt {
case arguments.ViewJSON:
return &OutputJSON{View: *view}
case arguments.ViewRaw:
return &OutputRaw{View: *view}
case arguments.ViewHuman:
return &OutputHuman{View: *view}
default:
panic(fmt.Sprintf("unknown view type %v", vt))
}
}
// The OutputHuman implementation renders outputs in a format equivalent to HCL
// source. This uses the same formatting logic as in the console REPL.
type OutputHuman struct {
View
}
var _ Output = (*OutputHuman)(nil)
func (v *OutputHuman) Output(name string, outputs map[string]*states.OutputValue) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if len(outputs) == 0 {
diags = diags.Append(noOutputsWarning())
return diags
}
if name != "" {
output, ok := outputs[name]
if !ok {
diags = diags.Append(missingOutputError(name))
return diags
}
result := repl.FormatValue(output.Value, 0)
v.output(result)
return nil
}
outputBuf := new(bytes.Buffer)
if len(outputs) > 0 {
// 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 {
v := outputs[k]
if v.Sensitive {
outputBuf.WriteString(fmt.Sprintf("%s = <sensitive>\n", k))
continue
}
result := repl.FormatValue(v.Value, 0)
outputBuf.WriteString(fmt.Sprintf("%s = %s\n", k, result))
}
}
v.output(strings.TrimSpace(outputBuf.String()))
return nil
}
// The OutputRaw implementation renders single string, number, or boolean
// output values directly and without quotes or other formatting. This is
// intended for use in shell scripting or other environments where the exact
// type of an output value is not important.
type OutputRaw struct {
View
// Unit tests may set rawPrint to capture the output from the Output
// method, which would normally go to stdout directly.
rawPrint func(string)
}
var _ Output = (*OutputRaw)(nil)
func (v *OutputRaw) Output(name string, outputs map[string]*states.OutputValue) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if len(outputs) == 0 {
diags = diags.Append(noOutputsWarning())
return diags
}
if name == "" {
diags = diags.Append(fmt.Errorf("Raw output format is only supported for single outputs"))
return diags
}
output, ok := outputs[name]
if !ok {
diags = diags.Append(missingOutputError(name))
return diags
}
strV, err := convert.Convert(output.Value, 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, output.Value.Type().FriendlyName(),
),
))
return diags
}
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,
),
))
return diags
}
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,
),
))
return diags
}
// 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()
fmt.Fprint(v.streams.Stdout.File, str)
return nil
}
// The OutputJSON implementation renders outputs as JSON values. When rendering
// a single output, only the value is displayed. When rendering all outputs,
// the result is a JSON object with keys matching the output names and object
// values including type and sensitivity metadata.
type OutputJSON struct {
View
}
var _ Output = (*OutputJSON)(nil)
func (v *OutputJSON) Output(name string, outputs map[string]*states.OutputValue) tfdiags.Diagnostics {
var diags tfdiags.Diagnostics
if name != "" {
output, ok := outputs[name]
if !ok {
diags = diags.Append(missingOutputError(name))
return diags
}
value := output.Value
jsonOutput, err := ctyjson.Marshal(value, value.Type())
if err != nil {
diags = diags.Append(err)
return diags
}
v.output(string(jsonOutput))
return nil
}
// Due to a historical accident, the switch from state version 2 to
// 3 caused our JSON output here to be the full metadata about the
// outputs rather than just the output values themselves as we'd
// show in the single value case. We must now maintain that behavior
// for compatibility, so this is an emulation of the JSON
// serialization of outputs used in state format version 3.
type OutputMeta struct {
Sensitive bool `json:"sensitive"`
Type json.RawMessage `json:"type"`
Value json.RawMessage `json:"value"`
}
outputMetas := map[string]OutputMeta{}
for n, os := range outputs {
jsonVal, err := ctyjson.Marshal(os.Value, os.Value.Type())
if err != nil {
diags = diags.Append(err)
return diags
}
jsonType, err := ctyjson.MarshalType(os.Value.Type())
if err != nil {
diags = diags.Append(err)
return diags
}
outputMetas[n] = OutputMeta{
Sensitive: os.Sensitive,
Type: json.RawMessage(jsonType),
Value: json.RawMessage(jsonVal),
}
}
jsonOutputs, err := json.MarshalIndent(outputMetas, "", " ")
if err != nil {
diags = diags.Append(err)
return diags
}
v.output(string(jsonOutputs))
return nil
}
// For text and raw output modes, an empty map of outputs is considered a
// separate and higher priority failure mode than an output not being present
// in a non-empty map. This warning diagnostic explains how this might have
// happened.
func noOutputsWarning() tfdiags.Diagnostic {
return tfdiags.Sourceless(
tfdiags.Warning,
"No outputs found",
"The state file either has no outputs defined, or all the defined "+
"outputs are empty. Please define an output in your configuration "+
"with the `output` keyword and run `terraform refresh` for it to "+
"become available. If you are using interpolation, please verify "+
"the interpolated value is not empty. You can use the "+
"`terraform console` command to assist.",
)
}
// Attempting to display a missing output results in this failure, which
// includes suggestions on how to rectify the problem.
func missingOutputError(name string) tfdiags.Diagnostic {
return tfdiags.Sourceless(
tfdiags.Error,
fmt.Sprintf("Output %q not found", name),
"The output variable requested could not be found in the state "+
"file. 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.",
)
}

View File

@ -0,0 +1,60 @@
package views
import (
"testing"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/states"
"github.com/zclconf/go-cty/cty"
)
func TestOutputRaw(t *testing.T) {
values := map[string]cty.Value{
"str": cty.StringVal("bar"),
"multistr": cty.StringVal("bar\nbaz"),
"num": cty.NumberIntVal(2),
"bool": cty.True,
"obj": cty.EmptyObjectVal,
"null": cty.NullVal(cty.String),
}
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) {
streams, done := terminal.StreamsForTesting(t)
view := NewView(streams)
v := &OutputRaw{
View: *view,
}
value := values[name]
outputs := map[string]*states.OutputValue{
name: {Value: value},
}
diags := v.Output(name, outputs)
if diags.HasErrors() {
if !test.WantErr {
t.Fatalf("unexpected diagnostics: %s", diags)
}
} else if test.WantErr {
t.Fatalf("succeeded, but want error")
}
if got, want := done(t).Stdout(), test.WantOutput; got != want {
t.Errorf("wrong result\ngot: %q\nwant: %q", got, want)
}
})
}
}

122
command/views/view.go Normal file
View File

@ -0,0 +1,122 @@
package views
import (
"fmt"
"github.com/hashicorp/terraform/command/arguments"
"github.com/hashicorp/terraform/command/format"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/tfdiags"
"github.com/mitchellh/colorstring"
)
// View is the base layer for command views, encapsulating a set of I/O
// streams, a colorize implementation, and implementing a human friendly view
// for diagnostics.
type View struct {
streams *terminal.Streams
colorize *colorstring.Colorize
// NOTE: compactWarnings is currently always false. When implementing
// views for commands which support this flag, we will need to address this.
compactWarnings bool
// This unfortunate wart is required to enable rendering of diagnostics which
// have associated source code in the configuration. This function pointer
// will be dereferenced as late as possible when rendering diagnostics in
// order to access the config loader cache.
configSources func() map[string][]byte
}
// Initialize a View with the given streams, a disabled colorize object, and a
// no-op configSources callback.
func NewView(streams *terminal.Streams) *View {
return &View{
streams: streams,
colorize: &colorstring.Colorize{
Colors: colorstring.DefaultColors,
Disable: true,
Reset: true,
},
configSources: func() map[string][]byte { return nil },
}
}
// Configure applies the global view configuration flags.
func (v *View) Configure(view *arguments.View) {
v.colorize.Disable = view.NoColor
v.compactWarnings = view.CompactWarnings
}
// SetConfigSources overrides the default no-op callback with a new function
// pointer, and should be called when the config loader is initialized.
func (v *View) SetConfigSources(cb func() map[string][]byte) {
v.configSources = cb
}
// Diagnostics renders a set of warnings and errors in human-readable form.
// Warnings are printed to stdout, and errors to stderr.
func (v *View) Diagnostics(diags tfdiags.Diagnostics) {
diags.Sort()
if len(diags) == 0 {
return
}
diags = diags.ConsolidateWarnings(1)
// Since warning messages are generally competing
if v.compactWarnings {
// If the user selected compact warnings and all of the diagnostics are
// warnings then we'll use a more compact representation of the warnings
// that only includes their summaries.
// We show full warnings if there are also errors, because a warning
// can sometimes serve as good context for a subsequent error.
useCompact := true
for _, diag := range diags {
if diag.Severity() != tfdiags.Warning {
useCompact = false
break
}
}
if useCompact {
msg := format.DiagnosticWarningsCompact(diags, v.colorize)
msg = "\n" + msg + "\nTo see the full warning notes, run Terraform without -compact-warnings.\n"
v.output(msg)
return
}
}
for _, diag := range diags {
var msg string
if v.colorize.Disable {
msg = format.DiagnosticPlain(diag, v.configSources(), v.streams.Stderr.Columns())
} else {
msg = format.Diagnostic(diag, v.configSources(), v.colorize, v.streams.Stderr.Columns())
}
if diag.Severity() == tfdiags.Error {
fmt.Fprint(v.streams.Stderr.File, msg)
} else {
fmt.Fprint(v.streams.Stdout.File, msg)
}
}
}
// HelpPrompt is intended to be called from commands which fail to parse all
// of their CLI arguments successfully. It refers users to the full help output
// rather than rendering it directly, which can be overwhelming and confusing.
func (v *View) HelpPrompt(command string) {
fmt.Fprintf(v.streams.Stderr.File, helpPrompt, command)
}
const helpPrompt = `
For more help on using this command, run:
terraform %s -help
`
// output is a shorthand for the common view operation of printing a string to
// the stdout stream, followed by a newline.
func (v *View) output(s string) {
fmt.Fprintln(v.streams.Stdout.File, s)
}

View File

@ -13,6 +13,7 @@ import (
"github.com/hashicorp/terraform/addrs"
"github.com/hashicorp/terraform/command"
"github.com/hashicorp/terraform/command/cliconfig"
"github.com/hashicorp/terraform/command/views"
"github.com/hashicorp/terraform/command/webbrowser"
"github.com/hashicorp/terraform/internal/getproviders"
"github.com/hashicorp/terraform/internal/terminal"
@ -81,6 +82,7 @@ func initCommands(
meta := command.Meta{
OriginalWorkingDir: originalWorkingDir,
Streams: streams,
View: views.NewView(streams),
Color: true,
GlobalPluginDirs: globalPluginDirs(),