cli: Migrate validate command to views

This commit is contained in:
Alisdair McDiarmid 2021-03-18 11:14:58 -04:00
parent c6278bbe37
commit dd380d0b58
6 changed files with 506 additions and 149 deletions

View File

@ -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
}

View File

@ -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))
}
})
}
}

View File

@ -1,14 +1,14 @@
package command package command
import ( import (
"encoding/json"
"fmt" "fmt"
"path/filepath" "path/filepath"
"strings" "strings"
"github.com/zclconf/go-cty/cty" "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/terraform"
"github.com/hashicorp/terraform/tfdiags" "github.com/hashicorp/terraform/tfdiags"
) )
@ -18,43 +18,37 @@ type ValidateCommand struct {
Meta Meta
} }
func (c *ValidateCommand) Run(args []string) int { func (c *ValidateCommand) Run(rawArgs []string) int {
args = c.Meta.process(args) // Parse and apply global view arguments
common, rawArgs := arguments.ParseView(rawArgs)
c.View.Configure(common)
var jsonOutput bool // Parse and validate flags
cmdFlags := c.Meta.defaultFlagSet("validate") args, diags := arguments.ParseValidate(rawArgs)
cmdFlags.BoolVar(&jsonOutput, "json", false, "produce JSON output") if diags.HasErrors() {
cmdFlags.Usage = func() { c.Ui.Error(c.Help()) } c.View.Diagnostics(diags)
if err := cmdFlags.Parse(args); err != nil { c.View.HelpPrompt("validate")
c.Ui.Error(fmt.Sprintf("Error parsing command-line flags: %s\n", err.Error()))
return 1 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 // 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 // enabled, so all errors should be accumulated into diags and we'll
// print out a suitable result at the end, depending on the format // print out a suitable result at the end, depending on the format
// selection. All returns from this point on must be tail-calls into // selection. All returns from this point on must be tail-calls into
// c.showResults in order to produce the expected output. // view.Results in order to produce the expected output.
args = cmdFlags.Args()
var dirPath string dir, err := filepath.Abs(args.Path)
if len(args) == 1 {
dirPath = args[0]
} else {
dirPath = "."
}
dir, err := filepath.Abs(dirPath)
if err != nil { if err != nil {
diags = diags.Append(fmt.Errorf("unable to locate module: %s", err)) 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 // Check for user-supplied plugin path
if c.pluginPath, err = c.loadPluginPath(); err != nil { if c.pluginPath, err = c.loadPluginPath(); err != nil {
diags = diags.Append(fmt.Errorf("error loading plugin path: %s", err)) diags = diags.Append(fmt.Errorf("error loading plugin path: %s", err))
return c.showResults(diags, jsonOutput) return view.Results(diags)
} }
validateDiags := c.validate(dir) validateDiags := c.validate(dir)
@ -66,7 +60,7 @@ func (c *ValidateCommand) Run(args []string) int {
// check before submitting a change. // check before submitting a change.
diags = diags.Append(c.providerDevOverrideRuntimeWarnings()) diags = diags.Append(c.providerDevOverrideRuntimeWarnings())
return c.showResults(diags, jsonOutput) return view.Results(diags)
} }
func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics { func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics {
@ -116,80 +110,13 @@ func (c *ValidateCommand) validate(dir string) tfdiags.Diagnostics {
return diags 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 { func (c *ValidateCommand) Synopsis() string {
return "Check whether the configuration is valid" return "Check whether the configuration is valid"
} }
func (c *ValidateCommand) Help() string { func (c *ValidateCommand) Help() string {
helpText := ` 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 Validate the configuration files in a directory, referring only to the
configuration and not accessing any remote services such as remote state, 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: validation without accessing any configured remote backend, use:
terraform init -backend=false 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 To verify configuration in the context of a particular run (a particular
target workspace, input variable values, etc), use the 'terraform plan' target workspace, input variable values, etc), use the 'terraform plan'
command instead, which includes an implied validation check. command instead, which includes an implied validation check.

View File

@ -9,15 +9,15 @@ import (
"testing" "testing"
"github.com/google/go-cmp/cmp" "github.com/google/go-cmp/cmp"
"github.com/mitchellh/cli"
"github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty"
"github.com/hashicorp/terraform/configs/configschema" "github.com/hashicorp/terraform/configs/configschema"
"github.com/hashicorp/terraform/internal/terminal"
"github.com/hashicorp/terraform/providers" "github.com/hashicorp/terraform/providers"
) )
func setupTest(fixturepath string, args ...string) (*cli.MockUi, int) { func setupTest(t *testing.T, fixturepath string, args ...string) (*terminal.TestOutput, int) {
ui := new(cli.MockUi) view, done := testView(t)
p := testProvider() p := testProvider()
p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{ p.GetProviderSchemaResponse = &providers.GetProviderSchemaResponse{
ResourceTypes: map[string]providers.Schema{ ResourceTypes: map[string]providers.Schema{
@ -45,19 +45,20 @@ func setupTest(fixturepath string, args ...string) (*cli.MockUi, int) {
c := &ValidateCommand{ c := &ValidateCommand{
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(p), testingOverrides: metaOverridesForProvider(p),
Ui: ui, View: view,
}, },
} }
args = append(args, "-no-color")
args = append(args, testFixturePath(fixturepath)) args = append(args, testFixturePath(fixturepath))
code := c.Run(args) code := c.Run(args)
return ui, code return done(t), code
} }
func TestValidateCommand(t *testing.T) { func TestValidateCommand(t *testing.T) {
if ui, code := setupTest("validate-valid"); code != 0 { if output, code := setupTest(t, "validate-valid"); code != 0 {
t.Fatalf("unexpected non-successful exit code %d\n\n%s", code, ui.ErrorWriter.String()) 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 os.RemoveAll(td)
defer testChdir(t, td)() defer testChdir(t, td)()
ui := new(cli.MockUi) view, done := testView(t)
c := &ValidateCommand{ c := &ValidateCommand{
Meta: Meta{ Meta: Meta{
testingOverrides: metaOverridesForProvider(testProvider()), testingOverrides: metaOverridesForProvider(testProvider()),
Ui: ui, View: view,
}, },
} }
args := []string{} args := []string{}
if code := c.Run(args); code != 0 { code := c.Run(args)
t.Fatalf("bad %d\n\n%s", code, ui.ErrorWriter.String()) output := done(t)
if code != 0 {
t.Fatalf("bad %d\n\n%s", code, output.Stderr())
} }
} }
func TestValidateFailingCommand(t *testing.T) { func TestValidateFailingCommand(t *testing.T) {
if ui, code := setupTest("validate-invalid"); code != 1 { if output, code := setupTest(t, "validate-invalid"); 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())
} }
} }
func TestValidateFailingCommandMissingQuote(t *testing.T) { func TestValidateFailingCommandMissingQuote(t *testing.T) {
ui, code := setupTest("validate-invalid/missing_quote") output, code := setupTest(t, "validate-invalid/missing_quote")
if code != 1 { 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" wantError := "Error: Invalid reference"
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
} }
func TestValidateFailingCommandMissingVariable(t *testing.T) { func TestValidateFailingCommandMissingVariable(t *testing.T) {
ui, code := setupTest("validate-invalid/missing_var") output, code := setupTest(t, "validate-invalid/missing_var")
if code != 1 { 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" wantError := "Error: Reference to undeclared input variable"
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
} }
func TestSameProviderMutipleTimesShouldFail(t *testing.T) { func TestSameProviderMutipleTimesShouldFail(t *testing.T) {
ui, code := setupTest("validate-invalid/multiple_providers") output, code := setupTest(t, "validate-invalid/multiple_providers")
if code != 1 { 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" wantError := "Error: Duplicate provider configuration"
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
} }
func TestSameModuleMultipleTimesShouldFail(t *testing.T) { func TestSameModuleMultipleTimesShouldFail(t *testing.T) {
ui, code := setupTest("validate-invalid/multiple_modules") output, code := setupTest(t, "validate-invalid/multiple_modules")
if code != 1 { 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" wantError := "Error: Duplicate module call"
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
} }
func TestSameResourceMultipleTimesShouldFail(t *testing.T) { func TestSameResourceMultipleTimesShouldFail(t *testing.T) {
ui, code := setupTest("validate-invalid/multiple_resources") output, code := setupTest(t, "validate-invalid/multiple_resources")
if code != 1 { 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` wantError := `Error: Duplicate resource "aws_instance" configuration`
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
} }
func TestOutputWithoutValueShouldFail(t *testing.T) { func TestOutputWithoutValueShouldFail(t *testing.T) {
ui, code := setupTest("validate-invalid/outputs") output, code := setupTest(t, "validate-invalid/outputs")
if code != 1 { 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.` wantError := `The argument "value" is required, but no definition was found.`
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) 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"?` wantError = `An argument named "values" is not expected here. Did you mean "value"?`
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
} }
func TestModuleWithIncorrectNameShouldFail(t *testing.T) { func TestModuleWithIncorrectNameShouldFail(t *testing.T) {
ui, code := setupTest("validate-invalid/incorrectmodulename") output, code := setupTest(t, "validate-invalid/incorrectmodulename")
if code != 1 { 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` wantError := `Error: Invalid module instance name`
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
wantError = `Error: Variables not allowed` wantError = `Error: Variables not allowed`
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
} }
func TestWronglyUsedInterpolationShouldFail(t *testing.T) { func TestWronglyUsedInterpolationShouldFail(t *testing.T) {
ui, code := setupTest("validate-invalid/interpolation") output, code := setupTest(t, "validate-invalid/interpolation")
if code != 1 { 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` wantError := `Error: Variables not allowed`
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
wantError = `A single static variable reference is required` wantError = `A single static variable reference is required`
if !strings.Contains(ui.ErrorWriter.String(), wantError) { if !strings.Contains(output.Stderr(), wantError) {
t.Fatalf("Missing error string %q\n\n'%s'", wantError, ui.ErrorWriter.String()) t.Fatalf("Missing error string %q\n\n'%s'", wantError, output.Stderr())
} }
} }
func TestMissingDefinedVar(t *testing.T) { 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 // This is allowed because validate tests only that variables are referenced
// correctly, not that they all have defined values. // correctly, not that they all have defined values.
if code != 0 { 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) 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) err = json.Unmarshal([]byte(gotString), &got)
if err != nil { if err != nil {
t.Fatalf("failed to unmarshal actual JSON: %s", err) 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) 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) t.Errorf("unexpected error output:\n%s", errorOutput)
} }
}) })

138
command/views/validate.go Normal file
View File

@ -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)
}

View File

@ -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)
}
})
}
}