From dd380d0b58dc9dda826d5057efc52eecde7c2c4e Mon Sep 17 00:00:00 2001 From: Alisdair McDiarmid Date: Thu, 18 Mar 2021 11:14:58 -0400 Subject: [PATCH] cli: Migrate validate command to views --- command/arguments/validate.go | 59 ++++++++++++ command/arguments/validate_test.go | 99 +++++++++++++++++++++ command/validate.go | 111 ++++------------------- command/validate_test.go | 115 ++++++++++++------------ command/views/validate.go | 138 +++++++++++++++++++++++++++++ command/views/validate_test.go | 133 +++++++++++++++++++++++++++ 6 files changed, 506 insertions(+), 149 deletions(-) create mode 100644 command/arguments/validate.go create mode 100644 command/arguments/validate_test.go create mode 100644 command/views/validate.go create mode 100644 command/views/validate_test.go diff --git a/command/arguments/validate.go b/command/arguments/validate.go new file mode 100644 index 000000000..71b31e09a --- /dev/null +++ b/command/arguments/validate.go @@ -0,0 +1,59 @@ +package arguments + +import ( + "github.com/hashicorp/terraform/tfdiags" +) + +// Validate represents the command-line arguments for the validate command. +type Validate struct { + // Path is the directory containing the configuration to be validated. If + // unspecified, validate will use the current directory. + Path string + + // ViewType specifies which output format to use: human, JSON, or "raw". + ViewType ViewType +} + +// ParseValidate processes CLI arguments, returning a Validate value and errors. +// If errors are encountered, a Validate value is still returned representing +// the best effort interpretation of the arguments. +func ParseValidate(args []string) (*Validate, tfdiags.Diagnostics) { + var diags tfdiags.Diagnostics + validate := &Validate{ + Path: ".", + } + + var jsonOutput bool + cmdFlags := defaultFlagSet("validate") + cmdFlags.BoolVar(&jsonOutput, "json", false, "json") + + 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, + "Too many command line arguments", + "Expected at most one positional argument.", + )) + } + + if len(args) > 0 { + validate.Path = args[0] + } + + switch { + case jsonOutput: + validate.ViewType = ViewJSON + default: + validate.ViewType = ViewHuman + } + + return validate, diags +} diff --git a/command/arguments/validate_test.go b/command/arguments/validate_test.go new file mode 100644 index 000000000..29b90d16c --- /dev/null +++ b/command/arguments/validate_test.go @@ -0,0 +1,99 @@ +package arguments + +import ( + "reflect" + "testing" + + "github.com/davecgh/go-spew/spew" + "github.com/hashicorp/terraform/tfdiags" +) + +func TestParseValidate_valid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Validate + }{ + "defaults": { + nil, + &Validate{ + Path: ".", + ViewType: ViewHuman, + }, + }, + "json": { + []string{"-json"}, + &Validate{ + Path: ".", + ViewType: ViewJSON, + }, + }, + "path": { + []string{"-json", "foo"}, + &Validate{ + Path: "foo", + ViewType: ViewJSON, + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, diags := ParseValidate(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 TestParseValidate_invalid(t *testing.T) { + testCases := map[string]struct { + args []string + want *Validate + wantDiags tfdiags.Diagnostics + }{ + "unknown flag": { + []string{"-boop"}, + &Validate{ + Path: ".", + ViewType: ViewHuman, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Failed to parse command-line flags", + "flag provided but not defined: -boop", + ), + }, + }, + "too many arguments": { + []string{"-json", "bar", "baz"}, + &Validate{ + Path: "bar", + ViewType: ViewJSON, + }, + tfdiags.Diagnostics{ + tfdiags.Sourceless( + tfdiags.Error, + "Too many command line arguments", + "Expected at most one positional argument.", + ), + }, + }, + } + + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + got, gotDiags := ParseValidate(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)) + } + }) + } +} diff --git a/command/validate.go b/command/validate.go index ea163cbdf..765a32921 100644 --- a/command/validate.go +++ b/command/validate.go @@ -1,14 +1,14 @@ package command import ( - "encoding/json" "fmt" "path/filepath" "strings" "github.com/zclconf/go-cty/cty" - viewsjson "github.com/hashicorp/terraform/command/views/json" + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/command/views" "github.com/hashicorp/terraform/terraform" "github.com/hashicorp/terraform/tfdiags" ) @@ -18,43 +18,37 @@ type ValidateCommand struct { Meta } -func (c *ValidateCommand) Run(args []string) int { - args = c.Meta.process(args) +func (c *ValidateCommand) Run(rawArgs []string) int { + // Parse and apply global view arguments + common, rawArgs := arguments.ParseView(rawArgs) + c.View.Configure(common) - var jsonOutput bool - cmdFlags := c.Meta.defaultFlagSet("validate") - cmdFlags.BoolVar(&jsonOutput, "json", false, "produce JSON output") - 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())) + // Parse and validate flags + args, diags := arguments.ParseValidate(rawArgs) + if diags.HasErrors() { + c.View.Diagnostics(diags) + c.View.HelpPrompt("validate") return 1 } - var diags tfdiags.Diagnostics + view := views.NewValidate(args.ViewType, c.View) // After this point, we must only produce JSON output if JSON mode is // enabled, so all errors should be accumulated into diags and we'll // print out a suitable result at the end, depending on the format // selection. All returns from this point on must be tail-calls into - // c.showResults in order to produce the expected output. - args = cmdFlags.Args() + // view.Results in order to produce the expected output. - var dirPath string - if len(args) == 1 { - dirPath = args[0] - } else { - dirPath = "." - } - dir, err := filepath.Abs(dirPath) + dir, err := filepath.Abs(args.Path) if err != nil { diags = diags.Append(fmt.Errorf("unable to locate module: %s", err)) - return c.showResults(diags, jsonOutput) + return view.Results(diags) } // Check for user-supplied plugin path if c.pluginPath, err = c.loadPluginPath(); err != nil { diags = diags.Append(fmt.Errorf("error loading plugin path: %s", err)) - return c.showResults(diags, jsonOutput) + return view.Results(diags) } validateDiags := c.validate(dir) @@ -66,7 +60,7 @@ func (c *ValidateCommand) Run(args []string) int { // check before submitting a change. diags = diags.Append(c.providerDevOverrideRuntimeWarnings()) - return c.showResults(diags, jsonOutput) + return view.Results(diags) } func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics { @@ -116,80 +110,13 @@ func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics { return diags } -func (c *ValidateCommand) showResults(diags tfdiags.Diagnostics, jsonOutput bool) int { - switch { - case jsonOutput: - // FormatVersion represents the version of the json format and will be - // incremented for any change to this format that requires changes to a - // consuming parser. - const FormatVersion = "0.1" - - type Output struct { - FormatVersion string `json:"format_version"` - - // We include some summary information that is actually redundant - // with the detailed diagnostics, but avoids the need for callers - // to re-implement our logic for deciding these. - Valid bool `json:"valid"` - ErrorCount int `json:"error_count"` - WarningCount int `json:"warning_count"` - Diagnostics []*viewsjson.Diagnostic `json:"diagnostics"` - } - - output := Output{ - FormatVersion: FormatVersion, - Valid: true, // until proven otherwise - } - configSources := c.configSources() - for _, diag := range diags { - output.Diagnostics = append(output.Diagnostics, viewsjson.NewDiagnostic(diag, configSources)) - - switch diag.Severity() { - case tfdiags.Error: - output.ErrorCount++ - output.Valid = false - case tfdiags.Warning: - output.WarningCount++ - } - } - if output.Diagnostics == nil { - // Make sure this always appears as an array in our output, since - // this is easier to consume for dynamically-typed languages. - output.Diagnostics = []*viewsjson.Diagnostic{} - } - - j, err := json.MarshalIndent(&output, "", " ") - if err != nil { - // Should never happen because we fully-control the input here - panic(err) - } - c.Ui.Output(string(j)) - - default: - if len(diags) == 0 { - c.Ui.Output(c.Colorize().Color("[green][bold]Success![reset] The configuration is valid.\n")) - } else { - c.showDiagnostics(diags) - - if !diags.HasErrors() { - c.Ui.Output(c.Colorize().Color("[green][bold]Success![reset] The configuration is valid, but there were some validation warnings as shown above.\n")) - } - } - } - - if diags.HasErrors() { - return 1 - } - return 0 -} - func (c *ValidateCommand) Synopsis() string { return "Check whether the configuration is valid" } func (c *ValidateCommand) Help() string { helpText := ` -Usage: terraform [global options] validate [options] [dir] +Usage: terraform [global options] validate [options] Validate the configuration files in a directory, referring only to the configuration and not accessing any remote services such as remote state, @@ -209,8 +136,6 @@ Usage: terraform [global options] validate [options] [dir] validation without accessing any configured remote backend, use: terraform init -backend=false - If dir is not specified, then the current directory will be used. - To verify configuration in the context of a particular run (a particular target workspace, input variable values, etc), use the 'terraform plan' command instead, which includes an implied validation check. diff --git a/command/validate_test.go b/command/validate_test.go index db0ff07f5..c6278da42 100644 --- a/command/validate_test.go +++ b/command/validate_test.go @@ -9,15 +9,15 @@ import ( "testing" "github.com/google/go-cmp/cmp" - "github.com/mitchellh/cli" "github.com/zclconf/go-cty/cty" "github.com/hashicorp/terraform/configs/configschema" + "github.com/hashicorp/terraform/internal/terminal" "github.com/hashicorp/terraform/providers" ) -func setupTest(fixturepath string, args ...string) (*cli.MockUi, int) { - ui := new(cli.MockUi) +func setupTest(t *testing.T, fixturepath string, args ...string) (*terminal.TestOutput, int) { + view, done := testView(t) p := testProvider() p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ ResourceTypes: map[string]providers.Schema{ @@ -45,19 +45,20 @@ func setupTest(fixturepath string, args ...string) (*cli.MockUi, int) { c := &ValidateCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(p), - Ui: ui, + View: view, }, } + args = append(args, "-no-color") args = append(args, testFixturePath(fixturepath)) code := c.Run(args) - return ui, code + return done(t), code } func TestValidateCommand(t *testing.T) { - if ui, code := setupTest("validate-valid"); code != 0 { - t.Fatalf("unexpected non-successful exit code %d\n\n%s", code, ui.ErrorWriter.String()) + if output, code := setupTest(t, "validate-valid"); code != 0 { + t.Fatalf("unexpected non-successful exit code %d\n\n%s", code, output.Stderr()) } } @@ -69,135 +70,137 @@ func TestValidateCommandWithTfvarsFile(t *testing.T) { defer os.RemoveAll(td) defer testChdir(t, td)() - ui := new(cli.MockUi) + view, done := testView(t) c := &ValidateCommand{ Meta: Meta{ testingOverrides: metaOverridesForProvider(testProvider()), - Ui: ui, + View: view, }, } 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()) } } func TestValidateFailingCommand(t *testing.T) { - if ui, code := setupTest("validate-invalid"); code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + if output, code := setupTest(t, "validate-invalid"); code != 1 { + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } } func TestValidateFailingCommandMissingQuote(t *testing.T) { - ui, code := setupTest("validate-invalid/missing_quote") + output, code := setupTest(t, "validate-invalid/missing_quote") if code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } wantError := "Error: Invalid reference" - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } } func TestValidateFailingCommandMissingVariable(t *testing.T) { - ui, code := setupTest("validate-invalid/missing_var") + output, code := setupTest(t, "validate-invalid/missing_var") if code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } wantError := "Error: Reference to undeclared input variable" - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } } func TestSameProviderMutipleTimesShouldFail(t *testing.T) { - ui, code := setupTest("validate-invalid/multiple_providers") + output, code := setupTest(t, "validate-invalid/multiple_providers") if code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } wantError := "Error: Duplicate provider configuration" - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } } func TestSameModuleMultipleTimesShouldFail(t *testing.T) { - ui, code := setupTest("validate-invalid/multiple_modules") + output, code := setupTest(t, "validate-invalid/multiple_modules") if code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } wantError := "Error: Duplicate module call" - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } } func TestSameResourceMultipleTimesShouldFail(t *testing.T) { - ui, code := setupTest("validate-invalid/multiple_resources") + output, code := setupTest(t, "validate-invalid/multiple_resources") if code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } wantError := `Error: Duplicate resource "aws_instance" configuration` - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } } func TestOutputWithoutValueShouldFail(t *testing.T) { - ui, code := setupTest("validate-invalid/outputs") + output, code := setupTest(t, "validate-invalid/outputs") if code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } wantError := `The argument "value" is required, but no definition was found.` - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } wantError = `An argument named "values" is not expected here. Did you mean "value"?` - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } } func TestModuleWithIncorrectNameShouldFail(t *testing.T) { - ui, code := setupTest("validate-invalid/incorrectmodulename") + output, code := setupTest(t, "validate-invalid/incorrectmodulename") if code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } wantError := `Error: Invalid module instance name` - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } wantError = `Error: Variables not allowed` - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } } func TestWronglyUsedInterpolationShouldFail(t *testing.T) { - ui, code := setupTest("validate-invalid/interpolation") + output, code := setupTest(t, "validate-invalid/interpolation") if code != 1 { - t.Fatalf("Should have failed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have failed: %d\n\n%s", code, output.Stderr()) } wantError := `Error: Variables not allowed` - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } wantError = `A single static variable reference is required` - if !strings.Contains(ui.ErrorWriter.String(), wantError) { - t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) + if !strings.Contains(output.Stderr(), wantError) { + t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr()) } } func TestMissingDefinedVar(t *testing.T) { - ui, code := setupTest("validate-invalid/missing_defined_var") + output, code := setupTest(t, "validate-invalid/missing_defined_var") // This is allowed because validate tests only that variables are referenced // correctly, not that they all have defined values. if code != 0 { - t.Fatalf("Should have passed: %d\n\n%s", code, ui.ErrorWriter.String()) + t.Fatalf("Should have passed: %d\n\n%s", code, output.Stderr()) } } @@ -237,9 +240,9 @@ func TestValidate_json(t *testing.T) { t.Fatalf("failed to unmarshal expected JSON: %s", err) } - ui, code := setupTest(tc.path, "-json") + output, code := setupTest(t, tc.path, "-json") - gotString := ui.OutputWriter.String() + gotString := output.Stdout() err = json.Unmarshal([]byte(gotString), &got) if err != nil { t.Fatalf("failed to unmarshal actual JSON: %s", err) @@ -256,7 +259,7 @@ func TestValidate_json(t *testing.T) { t.Errorf("wrong exit code: want 1, got %d", code) } - if errorOutput := ui.ErrorWriter.String(); errorOutput != "" { + if errorOutput := output.Stderr(); errorOutput != "" { t.Errorf("unexpected error output:\n%s", errorOutput) } }) diff --git a/command/views/validate.go b/command/views/validate.go new file mode 100644 index 000000000..864f4e294 --- /dev/null +++ b/command/views/validate.go @@ -0,0 +1,138 @@ +package views + +import ( + "encoding/json" + "fmt" + + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/command/format" + viewsjson "github.com/hashicorp/terraform/command/views/json" + "github.com/hashicorp/terraform/tfdiags" +) + +// The Validate is used for the validate command. +type Validate interface { + // Results renders the diagnostics returned from a validation walk, and + // returns a CLI exit code: 0 if there are no errors, 1 otherwise + Results(diags tfdiags.Diagnostics) int + + // Diagnostics renders early diagnostics, resulting from argument parsing. + Diagnostics(diags tfdiags.Diagnostics) +} + +// NewValidate returns an initialized Validate implementation for the given ViewType. +func NewValidate(vt arguments.ViewType, view *View) Validate { + switch vt { + case arguments.ViewJSON: + return &ValidateJSON{view: view} + case arguments.ViewHuman: + return &ValidateHuman{view: view} + default: + panic(fmt.Sprintf("unknown view type %v", vt)) + } +} + +// The ValidateHuman implementation renders diagnostics in a human-readable form, +// along with a success/failure message if Terraform is able to execute the +// validation walk. +type ValidateHuman struct { + view *View +} + +var _ Validate = (*ValidateHuman)(nil) + +func (v *ValidateHuman) Results(diags tfdiags.Diagnostics) int { + columns := v.view.outputColumns() + + if len(diags) == 0 { + v.view.streams.Println(format.WordWrap(v.view.colorize.Color(validateSuccess), columns)) + } else { + v.Diagnostics(diags) + + if !diags.HasErrors() { + v.view.streams.Println(format.WordWrap(v.view.colorize.Color(validateWarnings), columns)) + } + } + + if diags.HasErrors() { + return 1 + } + return 0 +} + +const validateSuccess = "[green][bold]Success![reset] The configuration is valid.\n" + +const validateWarnings = "[green][bold]Success![reset] The configuration is valid, but there were some validation warnings as shown above.\n" + +func (v *ValidateHuman) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} + +// The ValidateJSON implementation renders validation results as a JSON object. +// This object includes top-level fields summarizing the result, and an array +// of JSON diagnostic objects. +type ValidateJSON struct { + view *View +} + +var _ Validate = (*ValidateJSON)(nil) + +func (v *ValidateJSON) Results(diags tfdiags.Diagnostics) int { + // FormatVersion represents the version of the json format and will be + // incremented for any change to this format that requires changes to a + // consuming parser. + const FormatVersion = "0.1" + + type Output struct { + FormatVersion string `json:"format_version"` + + // We include some summary information that is actually redundant + // with the detailed diagnostics, but avoids the need for callers + // to re-implement our logic for deciding these. + Valid bool `json:"valid"` + ErrorCount int `json:"error_count"` + WarningCount int `json:"warning_count"` + Diagnostics []*viewsjson.Diagnostic `json:"diagnostics"` + } + + output := Output{ + FormatVersion: FormatVersion, + Valid: true, // until proven otherwise + } + configSources := v.view.configSources() + for _, diag := range diags { + output.Diagnostics = append(output.Diagnostics, viewsjson.NewDiagnostic(diag, configSources)) + + switch diag.Severity() { + case tfdiags.Error: + output.ErrorCount++ + output.Valid = false + case tfdiags.Warning: + output.WarningCount++ + } + } + if output.Diagnostics == nil { + // Make sure this always appears as an array in our output, since + // this is easier to consume for dynamically-typed languages. + output.Diagnostics = []*viewsjson.Diagnostic{} + } + + j, err := json.MarshalIndent(&output, "", " ") + if err != nil { + // Should never happen because we fully-control the input here + panic(err) + } + v.view.streams.Println(string(j)) + + if diags.HasErrors() { + return 1 + } + return 0 +} + +// Diagnostics should only be called if the validation walk cannot be executed. +// In this case, we choose to render human-readable diagnostic output, +// primarily for backwards compatibility. +func (v *ValidateJSON) Diagnostics(diags tfdiags.Diagnostics) { + v.view.Diagnostics(diags) +} diff --git a/command/views/validate_test.go b/command/views/validate_test.go new file mode 100644 index 000000000..65969a410 --- /dev/null +++ b/command/views/validate_test.go @@ -0,0 +1,133 @@ +package views + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/hashicorp/terraform/command/arguments" + "github.com/hashicorp/terraform/internal/terminal" + "github.com/hashicorp/terraform/tfdiags" +) + +func TestValidateHuman(t *testing.T) { + testCases := map[string]struct { + diag tfdiags.Diagnostic + wantSuccess bool + wantSubstring string + }{ + "success": { + nil, + true, + "The configuration is valid.", + }, + "warning": { + tfdiags.Sourceless( + tfdiags.Warning, + "Your shoelaces are untied", + "Watch out, or you'll trip!", + ), + true, + "The configuration is valid, but there were some validation warnings", + }, + "error": { + tfdiags.Sourceless( + tfdiags.Error, + "Configuration is missing random_pet", + "Every configuration should have a random_pet.", + ), + false, + "Error: Configuration is missing random_pet", + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + view.Configure(&arguments.View{NoColor: true}) + v := NewValidate(arguments.ViewHuman, view) + + var diags tfdiags.Diagnostics + + if tc.diag != nil { + diags = diags.Append(tc.diag) + } + + ret := v.Results(diags) + + if tc.wantSuccess && ret != 0 { + t.Errorf("expected 0 return code, got %d", ret) + } else if !tc.wantSuccess && ret != 1 { + t.Errorf("expected 1 return code, got %d", ret) + } + + got := done(t).All() + if strings.Contains(got, "Success!") != tc.wantSuccess { + t.Errorf("unexpected output:\n%s", got) + } + if !strings.Contains(got, tc.wantSubstring) { + t.Errorf("expected output to include %q, but was:\n%s", tc.wantSubstring, got) + } + }) + } +} + +func TestValidateJSON(t *testing.T) { + testCases := map[string]struct { + diag tfdiags.Diagnostic + wantSuccess bool + }{ + "success": { + nil, + true, + }, + "warning": { + tfdiags.Sourceless( + tfdiags.Warning, + "Your shoelaces are untied", + "Watch out, or you'll trip!", + ), + true, + }, + "error": { + tfdiags.Sourceless( + tfdiags.Error, + "Configuration is missing random_pet", + "Every configuration should have a random_pet.", + ), + false, + }, + } + for name, tc := range testCases { + t.Run(name, func(t *testing.T) { + streams, done := terminal.StreamsForTesting(t) + view := NewView(streams) + view.Configure(&arguments.View{NoColor: true}) + v := NewValidate(arguments.ViewJSON, view) + + var diags tfdiags.Diagnostics + + if tc.diag != nil { + diags = diags.Append(tc.diag) + } + + ret := v.Results(diags) + + if tc.wantSuccess && ret != 0 { + t.Errorf("expected 0 return code, got %d", ret) + } else if !tc.wantSuccess && ret != 1 { + t.Errorf("expected 1 return code, got %d", ret) + } + + got := done(t).All() + + // Make sure the result looks like JSON; we comprehensively test + // the structure of this output in the command package tests. + var result map[string]interface{} + + if err := json.Unmarshal([]byte(got), &result); err != nil { + t.Fatal(err) + } + }) + } +}