From 50d58b592a3bc622dc850910d703d08a1f910f0f Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Mon, 22 Feb 2021 11:38:39 -0500 Subject: [PATCH] cli: Migrate refresh to command views --- command/arguments/refresh.go | 61 +++++++++ command/arguments/refresh_test.go | 173 ++++++++++++++++++++++++++ command/refresh.go | 198 +++++++++++++++++++----------- command/refresh_test.go | 154 +++++++++++------------ command/views/refresh.go | 65 ++++++++++ command/views/refresh_test.go | 73 +++++++++++ 6 files changed, 577 insertions(+), 147 deletions(-) create mode 100644 command/arguments/refresh.go create mode 100644 command/arguments/refresh_test.go create mode 100644 command/views/refresh.go create mode 100644 command/views/refresh_test.go diff --git a/command/arguments/refresh.go b/command/arguments/refresh.go new file mode 100644 index 000000000..56336e3a6 --- /dev/null +++ b/command/arguments/refresh.go @@ -0,0 +1,61 @@ +package arguments + +import ( + "github.com/hashicorp/terraform/tfdiags" +) + +// Refresh represents the command-line arguments for the apply command. +type Refresh struct { + // State, Operation, and Vars are the common extended flags + State *State + Operation *Operation + Vars *Vars + + // InputEnabled is used to disable interactive input for unspecified + // variable and backend config values. Default is true. + InputEnabled bool + + // ViewType specifies which output format to use + ViewType ViewType +} + +// ParseRefresh processes CLI arguments, returning a Refresh value and errors. +// If errors are encountered, a Refresh value is still returned representing +// the best effort interpretation of the arguments. +func ParseRefresh(args []string) (*Refresh, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + refresh := &Refresh{ + State: &State{}, + Operation: &Operation{}, + Vars: &Vars{}, + } + + cmdFlags := extendedFlagSet("refresh", refresh.State, refresh.Operation, refresh.Vars) + cmdFlags.BoolVar(&refresh.InputEnabled, "input", true, "input") + + 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) > 0 { + diags = diags.Append(tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected at most one positional argument.", + )) + } + + diags = diags.Append(refresh.Operation.Parse()) + + switch { + default: + refresh.ViewType = ViewHuman + } + + return refresh, diags +} diff --git a/command/arguments/refresh_test.go b/command/arguments/refresh_test.go new file mode 100644 index 000000000..0f7676417 --- /dev/null +++ b/command/arguments/refresh_test.go @@ -0,0 +1,173 @@ +package arguments + +import ( + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/hashicorp/terraform/addrs" +) + +func TestParseRefresh_basicValid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Refresh + }{ + "defaults": { + nil, + &Refresh{ + InputEnabled: true, + ViewType: ViewHuman, + }, + }, + "input=flase": { + []string{"-input=false"}, + &Refresh{ + InputEnabled: false, + ViewType: ViewHuman, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseRefresh(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + // Ignore the extended arguments for simplicity + got.State = nil + got.Operation = nil + got.Vars = nil + if *got != *tc.want { + t.Fatalf("unexpected result\n got: %#v\nwant: %#v", got, tc.want) + } + }) + } +} + +func TestParseRefresh_invalid(t *testing.T) { + got, diags := ParseRefresh([]string{"-frob"}) + if len(diags) == 0 { + t.Fatal("expected diags but got none") + } + if got, want := diags.Err().Error(), "flag provided but not defined"; !strings.Contains(got, want) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) + } + if got.ViewType != ViewHuman { + t.Fatalf("wrong view type, got %#v, want %#v", got.ViewType, ViewHuman) + } +} + +func TestParseRefresh_tooManyArguments(t *testing.T) { + got, diags := ParseRefresh([]string{"saved.tfplan"}) + if len(diags) == 0 { + t.Fatal("expected diags but got none") + } + if got, want := diags.Err().Error(), "Too many command line arguments"; !strings.Contains(got, want) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, want) + } + if got.ViewType != ViewHuman { + t.Fatalf("wrong view type, got %#v, want %#v", got.ViewType, ViewHuman) + } +} + +func TestParseRefresh_targets(t *testing.T) { + foobarbaz, _ := addrs.ParseTargetStr("foo_bar.baz") + boop, _ := addrs.ParseTargetStr("module.boop") + testCases := map[string]struct { + args []string + want []addrs.Targetable + wantErr string + }{ + "no targets by default": { + args: nil, + want: nil, + }, + "one target": { + args: []string{"-target=foo_bar.baz"}, + want: []addrs.Targetable{foobarbaz.Subject}, + }, + "two targets": { + args: []string{"-target=foo_bar.baz", "-target", "module.boop"}, + want: []addrs.Targetable{foobarbaz.Subject, boop.Subject}, + }, + "invalid traversal": { + args: []string{"-target=foo."}, + want: nil, + wantErr: "Dot must be followed by attribute name", + }, + "invalid target": { + args: []string{"-target=data[0].foo"}, + want: nil, + wantErr: "A data source name is required", + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseRefresh(tc.args) + if len(diags) > 0 { + if tc.wantErr == "" { + t.Fatalf("unexpected diags: %v", diags) + } else if got := diags.Err().Error(); !strings.Contains(got, tc.wantErr) { + t.Fatalf("wrong diags\n got: %s\nwant: %s", got, tc.wantErr) + } + } + if !cmp.Equal(got.Operation.Targets, tc.want) { + t.Fatalf("unexpected result\n%s", cmp.Diff(got.Operation.Targets, tc.want)) + } + }) + } +} + +func TestParseRefresh_vars(t *testing.T) { + testCases := map[string]struct { + args []string + want []FlagNameValue + }{ + "no var flags by default": { + args: nil, + want: nil, + }, + "one var": { + args: []string{"-var", "foo=bar"}, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + }, + }, + "one var-file": { + args: []string{"-var-file", "cool.tfvars"}, + want: []FlagNameValue{ + {Name: "-var-file", Value: "cool.tfvars"}, + }, + }, + "ordering preserved": { + args: []string{ + "-var", "foo=bar", + "-var-file", "cool.tfvars", + "-var", "boop=beep", + }, + want: []FlagNameValue{ + {Name: "-var", Value: "foo=bar"}, + {Name: "-var-file", Value: "cool.tfvars"}, + {Name: "-var", Value: "boop=beep"}, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseRefresh(tc.args) + if len(diags) > 0 { + t.Fatalf("unexpected diags: %v", diags) + } + if vars := got.Vars.All(); !cmp.Equal(vars, tc.want) { + t.Fatalf("unexpected result\n%s", cmp.Diff(vars, tc.want)) + } + if got, want := got.Vars.Empty(), len(tc.want) == 0; got != want { + t.Fatalf("expected Empty() to return %t, but was %t", want, got) + } + }) + } +} diff --git a/command/refresh.go b/command/refresh.go index ebd609b01..d8cd89030 100644 --- a/command/refresh.go +++ b/command/refresh.go @@ -7,7 +7,6 @@ import ( "github.com/hashicorp/terraform/backend" "github.com/hashicorp/terraform/command/arguments" "github.com/hashicorp/terraform/command/views" - "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" ) @@ -17,107 +16,164 @@ type RefreshCommand struct { Meta } -func (c *RefreshCommand) Run(args []string) int { - args = c.Meta.process(args) - cmdFlags := c.Meta.extendedFlagSet("refresh") - cmdFlags.StringVar(&c.Meta.statePath, "state", "", "path") - cmdFlags.IntVar(&c.Meta.parallelism, "parallelism", DefaultParallelism, "parallelism") - cmdFlags.StringVar(&c.Meta.stateOutPath, "state-out", "", "path") - cmdFlags.StringVar(&c.Meta.backupPath, "backup", "", "path") - cmdFlags.BoolVar(&c.Meta.stateLock, "lock", true, "lock state") - cmdFlags.DurationVar(&c.Meta.stateLockTimeout, "lock-timeout", 0, "lock timeout") - 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())) - return 1 - } +func (c *RefreshCommand) 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.ParseRefresh(rawArgs) + + // Instantiate the view, even if there are flag errors, so that we render + // diagnostics according to the desired view + var view views.Refresh + view = views.NewRefresh(args.ViewType, c.RunningInAutomation, c.View) - diags := c.parseTargetFlags() if diags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - - configPath, err := ModulePath(cmdFlags.Args()) - if err != nil { - c.Ui.Error(err.Error()) + view.Diagnostics(diags) + view.HelpPrompt("refresh") return 1 } // Check for user-supplied plugin path + var err error if c.pluginPath, err = c.loadPluginPath(); err != nil { - c.Ui.Error(fmt.Sprintf("Error loading plugin path: %s", err)) + diags = diags.Append(err) + view.Diagnostics(diags) return 1 } - backendConfig, configDiags := c.loadBackendConfig(configPath) - diags = diags.Append(configDiags) - if configDiags.HasErrors() { - c.showDiagnostics(diags) + // FIXME: the -input flag value is needed to initialize the backend and the + // operation, but there is no clear path to pass this value down, so we + // continue to mutate the Meta object state for now. + c.Meta.input = args.InputEnabled + + // FIXME: the -parallelism flag is used to control the concurrency of + // Terraform operations. At the moment, this value is used both to + // initialize the backend via the ContextOpts field inside CLIOpts, and to + // set a largely unused field on the Operation request. Again, there is no + // clear path to pass this value down, so we continue to mutate the Meta + // object state for now. + c.Meta.parallelism = args.Operation.Parallelism + + // Prepare the backend with the backend-specific arguments + be, beDiags := c.PrepareBackend(args.State) + diags = diags.Append(beDiags) + if diags.HasErrors() { + view.Diagnostics(diags) return 1 } - // Load the backend - b, backendDiags := c.Backend(&BackendOpts{ - Config: backendConfig, - }) - diags = diags.Append(backendDiags) - if backendDiags.HasErrors() { - c.showDiagnostics(diags) + // Build the operation request + opReq, opDiags := c.OperationRequest(be, view, args.Operation) + diags = diags.Append(opDiags) + if diags.HasErrors() { + view.Diagnostics(diags) + return 1 + } + + // Collect variable value and add them to the operation request + diags = diags.Append(c.GatherVariables(opReq, args.Vars)) + if diags.HasErrors() { + view.Diagnostics(diags) return 1 } // Before we delegate to the backend, we'll print any warning diagnostics // we've accumulated here, since the backend will start fresh with its own // diagnostics. - c.showDiagnostics(diags) + view.Diagnostics(diags) diags = nil - // Build the operation - opReq := c.Operation(b) - opReq.ConfigDir = configPath - opReq.Hooks = []terraform.Hook{c.uiHook()} - opReq.ShowDiagnostics = c.showDiagnostics - opReq.Type = backend.OperationTypeRefresh - opReq.View = views.NewOperation(arguments.ViewHuman, c.RunningInAutomation, c.View) - - opReq.ConfigLoader, err = c.initConfigLoader() + // Perform the operation + op, err := c.RunOperation(be, opReq) if err != nil { - c.showDiagnostics(err) + diags = diags.Append(err) + view.Diagnostics(diags) return 1 } - { - var moreDiags tfdiags.Diagnostics - opReq.Variables, moreDiags = c.collectVariableValues() - diags = diags.Append(moreDiags) - if moreDiags.HasErrors() { - c.showDiagnostics(diags) - return 1 - } - } - - op, err := c.RunOperation(b, opReq) - if err != nil { - c.showDiagnostics(err) - return 1 - } - if op.Result != backend.OperationSuccess { - return op.Result.ExitStatus() - } - 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) - } + view.Outputs(op.State.RootModule().OutputValues) } return op.Result.ExitStatus() } +func (c *RefreshCommand) PrepareBackend(args *arguments.State) (backend.Enhanced, tfdiags.Diagnostics) { + // FIXME: we need to apply the state arguments to the meta object here + // because they are later used when initializing the backend. Carving a + // path to pass these arguments to the functions that need them is + // difficult but would make their use easier to understand. + c.Meta.applyStateArguments(args) + + backendConfig, diags := c.loadBackendConfig(".") + if diags.HasErrors() { + return nil, diags + } + + // Load the backend + be, beDiags := c.Backend(&BackendOpts{ + Config: backendConfig, + }) + diags = diags.Append(beDiags) + if beDiags.HasErrors() { + return nil, diags + } + + return be, diags +} + +func (c *RefreshCommand) OperationRequest(be backend.Enhanced, view views.Refresh, args *arguments.Operation, +) (*backend.Operation, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + + // Build the operation + opReq := c.Operation(be) + opReq.ConfigDir = "." + opReq.Hooks = view.Hooks() + opReq.Targets = args.Targets + opReq.Type = backend.OperationTypeRefresh + opReq.View = view.Operation() + // FIXME: this shim is needed until the remote backend is migrated to views + opReq.ShowDiagnostics = func(vals ...interface{}) { + var diags tfdiags.Diagnostics + diags = diags.Append(vals...) + view.Diagnostics(diags) + } + + var err error + opReq.ConfigLoader, err = c.initConfigLoader() + if err != nil { + diags = diags.Append(fmt.Errorf("Failed to initialize config loader: %s", err)) + return nil, diags + } + + return opReq, diags +} + +func (c *RefreshCommand) GatherVariables(opReq *backend.Operation, args *arguments.Vars) tfdiags.Diagnostics { + var diags tfdiags.Diagnostics + + // FIXME the arguments package currently trivially gathers variable related + // arguments in a heterogenous slice, in order to minimize the number of + // code paths gathering variables during the transition to this structure. + // Once all commands that gather variables have been converted to this + // structure, we could move the variable gathering code to the arguments + // package directly, removing this shim layer. + + varArgs := args.All() + items := make([]rawFlag, len(varArgs)) + for i := range varArgs { + items[i].Name = varArgs[i].Name + items[i].Value = varArgs[i].Value + } + c.Meta.variableArgs = rawFlags{items: &items} + opReq.Variables, diags = c.collectVariableValues() + + return diags +} + func (c *RefreshCommand) Help() string { helpText := ` Usage: terraform refresh [options] diff --git a/command/refresh_test.go b/command/refresh_test.go index ade794e1b..407b177d4 100644 --- a/command/refresh_test.go +++ b/command/refresh_test.go @@ -37,12 +37,10 @@ func TestRefresh(t *testing.T) { statePath := testStateFile(t, state) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -58,8 +56,10 @@ func TestRefresh(t *testing.T) { args := []string{ "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if !p.ReadResourceCalled { @@ -92,12 +92,10 @@ func TestRefresh_empty(t *testing.T) { defer testChdir(t, td)() p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -110,8 +108,10 @@ func TestRefresh_empty(t *testing.T) { } args := []string{} - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if p.ReadResourceCalled { @@ -136,12 +136,10 @@ func TestRefresh_lockedState(t *testing.T) { defer unlock() p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -158,13 +156,15 @@ func TestRefresh_lockedState(t *testing.T) { "-state", statePath, } - if code := c.Run(args); code == 0 { + code := c.Run(args) + output := done(t) + if code == 0 { t.Fatal("expected error") } - output := ui.ErrorWriter.String() - if !strings.Contains(output, "lock") { - t.Fatal("command output does not look like a lock error:", output) + got := output.Stderr() + if !strings.Contains(got, "lock") { + t.Fatal("command output does not look like a lock error:", got) } } @@ -182,12 +182,10 @@ func TestRefresh_cwd(t *testing.T) { statePath := testStateFile(t, state) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -203,8 +201,10 @@ func TestRefresh_cwd(t *testing.T) { args := []string{ "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if !p.ReadResourceCalled { @@ -262,12 +262,10 @@ func TestRefresh_defaultState(t *testing.T) { defer os.Chdir(cwd) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -283,8 +281,10 @@ func TestRefresh_defaultState(t *testing.T) { args := []string{ "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if !p.ReadResourceCalled { @@ -332,12 +332,10 @@ func TestRefresh_outPath(t *testing.T) { os.Remove(outPath) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -354,8 +352,10 @@ func TestRefresh_outPath(t *testing.T) { "-state", statePath, "-state-out", outPath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } newState := testStateRead(t, statePath) @@ -393,12 +393,10 @@ func TestRefresh_var(t *testing.T) { statePath := testStateFile(t, state) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -408,8 +406,10 @@ func TestRefresh_var(t *testing.T) { "-var", "foo=bar", "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if !p.ConfigureProviderCalled { @@ -431,12 +431,10 @@ func TestRefresh_varFile(t *testing.T) { statePath := testStateFile(t, state) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -451,8 +449,10 @@ func TestRefresh_varFile(t *testing.T) { "-var-file", varFilePath, "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if !p.ConfigureProviderCalled { @@ -474,12 +474,10 @@ func TestRefresh_varFileDefault(t *testing.T) { statePath := testStateFile(t, state) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -493,8 +491,10 @@ func TestRefresh_varFileDefault(t *testing.T) { args := []string{ "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } if !p.ConfigureProviderCalled { @@ -523,7 +523,7 @@ func TestRefresh_varsUnset(t *testing.T) { p := testProvider() ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), @@ -547,8 +547,10 @@ func TestRefresh_varsUnset(t *testing.T) { args := []string{ "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } } @@ -587,12 +589,10 @@ func TestRefresh_backup(t *testing.T) { os.Remove(backupPath) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -610,8 +610,10 @@ func TestRefresh_backup(t *testing.T) { "-state-out", outPath, "-backup", backupPath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } newState := testStateRead(t, statePath) @@ -658,12 +660,10 @@ func TestRefresh_disableBackup(t *testing.T) { os.Remove(outPath) p := testProvider() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -681,8 +681,10 @@ func TestRefresh_disableBackup(t *testing.T) { "-state-out", outPath, "-backup", "-", } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } newState := testStateRead(t, statePath) @@ -725,12 +727,10 @@ func TestRefresh_displaysOutputs(t *testing.T) { statePath := testStateFile(t, state) p := testProvider() - ui := new(cli.MockUi) view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -750,13 +750,15 @@ func TestRefresh_displaysOutputs(t *testing.T) { args := []string{ "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } // Test that outputs were displayed outputValue := "foo.example.com" - actual := done(t).Stdout() + actual := output.Stdout() if !strings.Contains(actual, outputValue) { t.Fatalf("Expected:\n%s\n\nTo include: %q", actual, outputValue) } @@ -790,12 +792,10 @@ func TestRefresh_targeted(t *testing.T) { } } - ui := new(cli.MockUi) view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, View: view, }, } @@ -804,11 +804,13 @@ func TestRefresh_targeted(t *testing.T) { "-target", "test_instance.foo", "-state", statePath, } - if code := c.Run(args); code != 0 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 0 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } - got := done(t).Stdout() + got := output.Stdout() if want := "test_instance.foo: Refreshing"; !strings.Contains(got, want) { t.Fatalf("expected output to contain %q, got:\n%s", want, got) } @@ -830,11 +832,9 @@ func TestRefresh_targetFlagsDiags(t *testing.T) { defer os.RemoveAll(td) defer testChdir(t, td)() - ui := new(cli.MockUi) - view, _ := testView(t) + view, done := testView(t) c := &RefreshCommand{ Meta: Meta{ - Ui: ui, View: view, }, } @@ -842,11 +842,13 @@ func TestRefresh_targetFlagsDiags(t *testing.T) { args := []string{ "-target", target, } - if code := c.Run(args); code != 1 { - t.Fatalf("bad: %d\n\n%s", code, ui.ErrorWriter.String()) + code := c.Run(args) + output := done(t) + if code != 1 { + t.Fatalf("bad: %d\n\n%s", code, output.Stderr()) } - got := ui.ErrorWriter.String() + got := output.Stderr() if !strings.Contains(got, target) { t.Fatalf("bad error output, want %q, got:\n%s", target, got) } diff --git a/command/views/refresh.go b/command/views/refresh.go new file mode 100644 index 000000000..f07a8fe2f --- /dev/null +++ b/command/views/refresh.go @@ -0,0 +1,65 @@ +package views + +import ( + "fmt" + + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/states" + "github.com/hashicorp/terraform/terraform" + "github.com/hashicorp/terraform/tfdiags" +) + +// The Refresh view is used for the refresh command. +type Refresh interface { + Outputs(outputValues map[string]*states.OutputValue) + + Operation() Operation + Hooks() []terraform.Hook + + Diagnostics(diags tfdiags.Diagnostics) + HelpPrompt(command string) +} + +// NewRefresh returns an initialized Refresh implementation for the given ViewType. +func NewRefresh(vt arguments.ViewType, runningInAutomation bool, view *View) Refresh { + switch vt { + case arguments.ViewHuman: + return &RefreshHuman{ + View: *view, + inAutomation: runningInAutomation, + countHook: &countHook{}, + } + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +// The RefreshHuman implementation renders human-readable text logs, suitable for +// a scrolling terminal. +type RefreshHuman struct { + View + + inAutomation bool + + countHook *countHook +} + +var _ Refresh = (*RefreshHuman)(nil) + +func (v *RefreshHuman) Outputs(outputValues map[string]*states.OutputValue) { + if len(outputValues) > 0 { + v.streams.Print(v.colorize.Color("[reset][bold][green]\nOutputs:\n\n")) + NewOutput(arguments.ViewHuman, &v.View).Output("", outputValues) + } +} + +func (v *RefreshHuman) Operation() Operation { + return NewOperation(arguments.ViewHuman, v.inAutomation, &v.View) +} + +func (v *RefreshHuman) Hooks() []terraform.Hook { + return []terraform.Hook{ + v.countHook, + NewUiHook(&v.View), + } +} diff --git a/command/views/refresh_test.go b/command/views/refresh_test.go new file mode 100644 index 000000000..05e6ffa0d --- /dev/null +++ b/command/views/refresh_test.go @@ -0,0 +1,73 @@ +package views + +import ( + "strings" + "testing" + + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/states" + "github.com/zclconf/go-cty/cty" +) + +// Ensure that the correct view type and in-automation settings propagate to the +// Operation view. +func TestRefreshHuman_operation(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + defer done(t) + v := NewRefresh(arguments.ViewHuman, true, NewView(streams)).Operation() + if hv, ok := v.(*OperationHuman); !ok { + t.Fatalf("unexpected return type %t", v) + } else if hv.inAutomation != true { + t.Fatalf("unexpected inAutomation value on Operation view") + } +} + +// Verify that Hooks includes a UI hook +func TestRefreshHuman_hooks(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + defer done(t) + v := NewRefresh(arguments.ViewHuman, true, NewView(streams)) + hooks := v.Hooks() + + var uiHook *UiHook + for _, hook := range hooks { + if ch, ok := hook.(*UiHook); ok { + uiHook = ch + } + } + if uiHook == nil { + t.Fatalf("expected Hooks to include a UiHook: %#v", hooks) + } +} + +// Basic test coverage of Outputs, since most of its functionality is tested +// elsewhere. +func TestRefreshHuman_outputs(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewRefresh(arguments.ViewHuman, false, NewView(streams)) + + v.Outputs(map[string]*states.OutputValue{ + "foo": {Value: cty.StringVal("secret")}, + }) + + got := done(t).Stdout() + for _, want := range []string{"Outputs:", `foo = "secret"`} { + if !strings.Contains(got, want) { + t.Errorf("wrong result\ngot: %q\nwant: %q", got, want) + } + } +} + +// Outputs should do nothing if there are no outputs to render. +func TestRefreshHuman_outputsEmpty(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + v := NewRefresh(arguments.ViewHuman, false, NewView(streams)) + + v.Outputs(map[string]*states.OutputValue{}) + + got := done(t).Stdout() + if got != "" { + t.Errorf("output should be empty, but got: %q", got) + } +}