diff --git a/README.md b/README.md index ed6624df9..0b1f97de3 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,7 @@ Application Options: --no-color Disable colorized output --fix Fix issues automatically --no-parallel-runners Disable per-runner parallelism + --max-workers=N Set maximum number of workers in recursive inspection (default: number of CPUs) Help Options: -h, --help Show this help message diff --git a/cmd/cli.go b/cmd/cli.go index d6eb9f2ed..bbfc1025c 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -6,8 +6,10 @@ import ( "io" "log" "os" + "os/signal" "path/filepath" "strings" + "syscall" "github.com/fatih/color" "github.com/hashicorp/logutils" @@ -63,6 +65,7 @@ func (cli *CLI) Run(args []string) int { Stdout: cli.outStream, Stderr: cli.errStream, Format: opts.Format, + Fix: opts.Fix, } if opts.Color { color.NoColor = false @@ -92,6 +95,10 @@ func (cli *CLI) Run(args []string) int { cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Command line arguments support was dropped in v0.47. Use --chdir or --filter instead."), map[string][]byte{}) return ExitCodeError } + if opts.MaxWorkers != nil && *opts.MaxWorkers <= 0 { + cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Max workers should be greater than 0"), map[string][]byte{}) + return ExitCodeError + } switch { case opts.Version: @@ -103,7 +110,11 @@ func (cli *CLI) Run(args []string) int { case opts.ActAsBundledPlugin: return cli.actAsBundledPlugin() default: - return cli.inspect(opts) + if opts.Recursive { + return cli.inspectParallel(opts) + } else { + return cli.inspect(opts) + } } } @@ -171,7 +182,7 @@ func findWorkingDirs(opts Options) ([]string, error) { } func (cli *CLI) withinChangedDir(dir string, proc func() error) (err error) { - if dir != "." { + if dir != "." && dir != "" { chErr := os.Chdir(dir) if chErr != nil { return fmt.Errorf("Failed to switch to a different working directory; %w", chErr) @@ -186,3 +197,16 @@ func (cli *CLI) withinChangedDir(dir string, proc func() error) (err error) { return proc() } + +func registerShutdownCh() <-chan os.Signal { + ch := make(chan os.Signal, 1) + signal.Notify(ch, os.Interrupt, syscall.SIGTERM) + return ch +} + +func (cli *CLI) registerShutdownHandler(callback func()) { + ch := registerShutdownCh() + sig := <-ch + fmt.Fprintf(cli.errStream, "Received %s, shutting down...\n", sig) + callback() +} diff --git a/cmd/inspect.go b/cmd/inspect.go index 874228e4e..460c8ac71 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "io" "os" @@ -19,78 +20,54 @@ import ( ) func (cli *CLI) inspect(opts Options) int { - // Respect the "--format" flag until a config is loaded - cli.formatter.Format = opts.Format - - workingDirs, err := findWorkingDirs(opts) - if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to find workspaces; %w", err), map[string][]byte{}) - return ExitCodeError - } - issues := tflint.Issues{} changes := map[string][]byte{} - for _, wd := range workingDirs { - err := cli.withinChangedDir(wd, func() error { - filterFiles := []string{} - for _, pattern := range opts.Filter { - files, err := filepath.Glob(pattern) - if err != nil { - return fmt.Errorf("Failed to parse --filter options; %w", err) - } - // Add the raw pattern to return an empty result if it doesn't match any files - if len(files) == 0 { - filterFiles = append(filterFiles, pattern) - } - filterFiles = append(filterFiles, files...) - } - - // Join with the working directory to create the fullpath - for i, file := range filterFiles { - filterFiles[i] = filepath.Join(wd, file) - } - - moduleIssues, moduleChanges, err := cli.inspectModule(opts, ".", filterFiles) + err := cli.withinChangedDir(opts.Chdir, func() error { + filterFiles := []string{} + for _, pattern := range opts.Filter { + files, err := filepath.Glob(pattern) if err != nil { - if len(workingDirs) > 1 { - // Print the current working directory in recursive inspection - return fmt.Errorf("%w working_dir=%s", err, wd) - } - return err + return fmt.Errorf("Failed to parse --filter options; %w", err) } - issues = append(issues, moduleIssues...) - for path, source := range moduleChanges { - changes[path] = source + // Add the raw pattern to return an empty result if it doesn't match any files + if len(files) == 0 { + filterFiles = append(filterFiles, pattern) } + filterFiles = append(filterFiles, files...) + } - return nil - }) - if err != nil { - sources := map[string][]byte{} - if cli.loader != nil { - sources = cli.loader.Sources() - } - cli.formatter.Print(tflint.Issues{}, err, sources) - return ExitCodeError + // Join with the working directory to create the fullpath + for i, file := range filterFiles { + filterFiles[i] = filepath.Join(opts.Chdir, file) } + + var err error + issues, changes, err = cli.inspectModule(opts, ".", filterFiles) + return err + }) + if err != nil { + sources := map[string][]byte{} + if cli.loader != nil { + sources = cli.loader.Sources() + } + cli.formatter.Print(tflint.Issues{}, err, sources) + return ExitCodeError } - var force bool - if opts.Recursive { - // Respect "--format" and "--force" flags in recursive mode - cli.formatter.Format = opts.Format - if opts.Force != nil { - force = *opts.Force + if opts.ActAsWorker { + // When acting as a recursive inspection worker, the formatter is ignored + // and the serialized issues are output. + out, err := json.Marshal(issues) + if err != nil { + fmt.Fprint(cli.errStream, err) + return ExitCodeError } + fmt.Fprint(cli.outStream, string(out)) } else { - cli.formatter.Format = cli.config.Format - force = cli.config.Force + cli.formatter.Print(issues, nil, cli.sources) } - cli.formatter.Fix = opts.Fix - cli.formatter.Print(issues, nil, cli.sources) - if opts.Fix { if err := writeChanges(changes); err != nil { cli.formatter.Print(tflint.Issues{}, err, cli.sources) @@ -98,7 +75,7 @@ func (cli *CLI) inspect(opts Options) int { } } - if len(issues) > 0 && !force && exceedsMinimumFailure(issues, opts.MinimumFailureSeverity) { + if len(issues) > 0 && !cli.config.Force && exceedsMinimumFailure(issues, opts.MinimumFailureSeverity) { return ExitCodeIssuesFound } @@ -122,8 +99,8 @@ func (cli *CLI) inspectModule(opts Options, dir string, filterFiles []string) (t if err != nil { return issues, changes, fmt.Errorf("Failed to prepare loading; %w", err) } - if opts.Recursive && !cli.loader.IsConfigDir(dir) { - // Ignore non-module directories in recursive mode + if opts.ActAsWorker && !cli.loader.IsConfigDir(dir) { + // Ignore non-module directories in worker mode return issues, changes, nil } @@ -137,6 +114,10 @@ func (cli *CLI) inspectModule(opts Options, dir string, filterFiles []string) (t rulesetPlugin, err := launchPlugins(cli.config, opts.Fix) if rulesetPlugin != nil { defer rulesetPlugin.Clean() + go cli.registerShutdownHandler(func() { + rulesetPlugin.Clean() + os.Exit(ExitCodeError) + }) } if err != nil { return issues, changes, err diff --git a/cmd/inspect_parallel.go b/cmd/inspect_parallel.go new file mode 100644 index 000000000..fb8298759 --- /dev/null +++ b/cmd/inspect_parallel.go @@ -0,0 +1,171 @@ +package cmd + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log" + "os" + "os/exec" + "runtime" + "sync" + "time" + + "github.com/terraform-linters/tflint/tflint" +) + +// worker is a struct to store the result of each directory +type worker struct { + dir string + stdout io.Reader + stderr io.Reader + err error +} + +func (cli *CLI) inspectParallel(opts Options) int { + workingDirs, err := findWorkingDirs(opts) + if err != nil { + cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to find workspaces; %w", err), map[string][]byte{}) + return ExitCodeError + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + go cli.registerShutdownHandler(cancel) + + workers, err := spawnWorkers(ctx, workingDirs, opts) + if err != nil { + cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to perform workers; %w", err), map[string][]byte{}) + return ExitCodeError + } + + issues := tflint.Issues{} + var canceled bool + + for worker := range workers { + stdout, err := io.ReadAll(worker.stdout) + if err != nil { + cli.formatter.PrintErrorParallel(fmt.Errorf("Failed to read stdout in %s; %w", worker.dir, err), cli.sources) + continue + } + stderr, err := io.ReadAll(worker.stderr) + if err != nil { + cli.formatter.PrintErrorParallel(fmt.Errorf("Failed to read stderr in %s; %w", worker.dir, err), cli.sources) + continue + } + if worker.err != nil { + // If the worker is canceled, suppress the error message. + if errors.Is(context.Canceled, worker.err) { + canceled = true + continue + } + + log.Printf("[DEBUG] Failed to run in %s; %s; stdout=%s", worker.dir, worker.err, stdout) + cli.formatter.PrintErrorParallel(fmt.Errorf("Failed to run in %s; %w\n\n%s", worker.dir, worker.err, stderr), cli.sources) + continue + } + + var workerIssues tflint.Issues + if err := json.Unmarshal(stdout, &workerIssues); err != nil { + panic(fmt.Errorf("failed to parse issues in %s; %s; stdout=%s; stderr=%s", worker.dir, err, stdout, stderr)) + } + issues = append(issues, workerIssues...) + + if len(stderr) > 0 { + // Regardless of format, output to stderr is synchronized. + cli.formatter.PrettyPrintStderr(fmt.Sprintf("An output to stderr found in %s\n\n%s\n", worker.dir, stderr)) + } + } + + if canceled { + // If the worker is canceled, suppress the error message. + return ExitCodeError + } + + var force bool + if opts.Force != nil { + force = *opts.Force + } + + if err := cli.formatter.PrintParallel(issues, cli.sources); err != nil { + return ExitCodeError + } + + if len(issues) > 0 && !force && exceedsMinimumFailure(issues, opts.MinimumFailureSeverity) { + return ExitCodeIssuesFound + } + + return ExitCodeOK +} + +// Spawn workers to run in parallel for each directory. +// A worker is a process that runs itself as a child process. +// The number of parallelism is controlled by --max-workers flag. The default is the number of CPUs. +func spawnWorkers(ctx context.Context, workingDirs []string, opts Options) (<-chan worker, error) { + self, err := os.Executable() + if err != nil { + return nil, err + } + + maxWorkers := runtime.NumCPU() + if opts.MaxWorkers != nil { + if c := *opts.MaxWorkers; c > 0 { + maxWorkers = c + } + } + + ch := make(chan worker) + semaphore := make(chan struct{}, maxWorkers) + + go func() { + defer close(ch) + + var wg sync.WaitGroup + for _, wd := range workingDirs { + wg.Add(1) + go func(wd string) { + defer wg.Done() + spawnWorker(ctx, self, wd, opts, ch, semaphore) + }(wd) + } + wg.Wait() + }() + + return ch, nil +} + +// Spawn a worker process for the given directory. +// When the process is complete, send the results to the given channel. +// If the context is canceled, the started process will be interrupted. +func spawnWorker(ctx context.Context, executable string, workingDir string, opts Options, ch chan<- worker, semaphore chan struct{}) { + // Blocks from exceeding the maximum number of workers + select { + case semaphore <- struct{}{}: + defer func() { + <-semaphore + }() + case <-ctx.Done(): + log.Printf("[DEBUG] Worker in %s is canceled\n", workingDir) + ch <- worker{dir: workingDir, stdout: new(bytes.Buffer), stderr: new(bytes.Buffer), err: ctx.Err()} + return + } + + stdout, stderr := new(bytes.Buffer), new(bytes.Buffer) + cmd := exec.CommandContext(ctx, executable, opts.toWorkerCommands(workingDir)...) + cmd.Stdout, cmd.Stderr = stdout, stderr + cmd.Cancel = func() error { + log.Printf("[DEBUG] Worker in %s is terminated\n", workingDir) + return cmd.Process.Signal(os.Interrupt) + } + cmd.WaitDelay = 3 * time.Second + err := cmd.Run() + if ctx.Err() != nil { + // If the context is canceled, return the context error instread of the command error. + err = ctx.Err() + } + + ch <- worker{dir: workingDir, stdout: stdout, stderr: stderr, err: err} +} diff --git a/cmd/langserver.go b/cmd/langserver.go index 8a9bce98e..ab56b7938 100644 --- a/cmd/langserver.go +++ b/cmd/langserver.go @@ -5,8 +5,6 @@ import ( "fmt" "log" "os" - "os/signal" - "syscall" "github.com/sourcegraph/jsonrpc2" "github.com/terraform-linters/tflint/langserver" @@ -36,8 +34,7 @@ func (cli *CLI) startLanguageServer(opts Options) int { defer plugin.Clean() } - ch := make(chan os.Signal, 1) - signal.Notify(ch, os.Interrupt, syscall.SIGTERM) + ch := registerShutdownCh() conn := jsonrpc2.NewConn( context.Background(), diff --git a/cmd/option.go b/cmd/option.go index d95f4f02a..2ad4fb223 100644 --- a/cmd/option.go +++ b/cmd/option.go @@ -36,7 +36,9 @@ type Options struct { NoColor bool `long:"no-color" description:"Disable colorized output"` Fix bool `long:"fix" description:"Fix issues automatically"` NoParallelRunners bool `long:"no-parallel-runners" description:"Disable per-runner parallelism"` + MaxWorkers *int `long:"max-workers" description:"Set maximum number of workers in recursive inspection (default: number of CPUs)" value-name:"N"` ActAsBundledPlugin bool `long:"act-as-bundled-plugin" hidden:"true"` + ActAsWorker bool `long:"act-as-worker" hidden:"true"` } func (opts *Options) toConfig() *tflint.Config { @@ -151,3 +153,77 @@ func (opts *Options) toConfig() *tflint.Config { Plugins: plugins, } } + +// Return commands to be executed by worker processes in recursive inspection. +// All possible CLI flags are delegated, but some flags are ignored because +// the coordinator process that starts the workers is responsible. +func (opts *Options) toWorkerCommands(workingDir string) []string { + commands := []string{ + "--act-as-worker", + "--chdir=" + workingDir, + "--force", // Exit status is always ignored + } + + // opts.Version, opts.Init, and opts.Langserver are not supported + + // opt.Format is ignored because workers always output serialized issues + + if opts.Config != "" { + commands = append(commands, fmt.Sprintf("--config=%s", opts.Config)) + } + for _, ignoreModule := range opts.IgnoreModules { + commands = append(commands, fmt.Sprintf("--ignore-module=%s", ignoreModule)) + } + for _, rule := range opts.EnableRules { + commands = append(commands, fmt.Sprintf("--enable-rule=%s", rule)) + } + for _, rule := range opts.DisableRules { + commands = append(commands, fmt.Sprintf("--disable-rule=%s", rule)) + } + for _, rule := range opts.Only { + commands = append(commands, fmt.Sprintf("--only=%s", rule)) + } + for _, plugin := range opts.EnablePlugins { + commands = append(commands, fmt.Sprintf("--enable-plugin=%s", plugin)) + } + for _, varfile := range opts.Varfiles { + commands = append(commands, fmt.Sprintf("--var-file=%s", varfile)) + } + for _, variable := range opts.Variables { + commands = append(commands, fmt.Sprintf("--var=%s", variable)) + } + if opts.Module != nil && *opts.Module { + commands = append(commands, "--module") + } + if opts.NoModule != nil && *opts.NoModule { + commands = append(commands, "--no-module") + } + if opts.CallModuleType != nil { + commands = append(commands, fmt.Sprintf("--call-module-type=%s", *opts.CallModuleType)) + } + + // opts.Chdir should be ignored because it is given by the coordinator + + // opts.Recursive is not supported + + for _, filter := range opts.Filter { + commands = append(commands, fmt.Sprintf("--filter=%s", filter)) + } + + // opts.Force and opts.MinimumFailureSeverity are ignored because exit status is controlled by the coordinator + + // opts.Color and opts.NoColor are ignored because the coordinator is responsible for colorized output + + if opts.Fix { + commands = append(commands, "--fix") + } + if opts.NoParallelRunners { + commands = append(commands, "--no-parallel-runners") + } + + // opts.MaxWorkers is ignored because the coordinator is responsible for parallelism + + // opts.ActAsBundledPlugin and opts.ActAsWorker are not supported + + return commands +} diff --git a/cmd/option_test.go b/cmd/option_test.go index 98492f81e..a6be55388 100644 --- a/cmd/option_test.go +++ b/cmd/option_test.go @@ -304,3 +304,123 @@ func Test_toConfig(t *testing.T) { }) } } + +func Test_toWorkerCommands(t *testing.T) { + tests := []struct { + name string + in []string + workingDir string + want []string + }{ + { + name: "no args", + in: []string{}, + workingDir: "subdir", + want: []string{"--act-as-worker", "--chdir=subdir", "--force"}, + }, + { + name: "all", + in: []string{ + "--version", + "--init", + "--langserver", + "--format=json", + "--config=tflint.hcl", + "--ignore-module=module1", + "--ignore-module=module2", + "--enable-rule=rule1", + "--enable-rule=rule2", + "--disable-rule=rule3", + "--disable-rule=rule4", + "--only=rule5", + "--only=rule6", + "--enable-plugin=plugin1", + "--enable-plugin=plugin2", + "--var-file=example1.tfvars", + "--var-file=example2.tfvars", + "--var=foo=bar", + "--var=bar=baz", + "--module", + "--no-module", + "--call-module-type=all", + "--chdir=dir", + "--recursive", + "--filter=main1.tf", + "--filter=main2.tf", + "--force", + "--minimum-failure-severity=warning", + "--color", + "--no-color", + "--fix", + "--no-parallel-runners", + "--max-workers=2", + "--act-as-bundled-plugin", + "--act-as-worker", + }, + workingDir: "subdir", + want: []string{ + // "--version", + // "--init", + // "--langserver", + // "--format=json", + "--config=tflint.hcl", + "--ignore-module=module1", + "--ignore-module=module2", + "--enable-rule=rule1", + "--enable-rule=rule2", + "--disable-rule=rule3", + "--disable-rule=rule4", + "--only=rule5", + "--only=rule6", + "--enable-plugin=plugin1", + "--enable-plugin=plugin2", + "--var-file=example1.tfvars", + "--var-file=example2.tfvars", + "--var=foo=bar", + "--var=bar=baz", + "--module", + "--no-module", + "--call-module-type=all", + "--chdir=subdir", // "--chdir=dir", + // "--recursive", + "--filter=main1.tf", + "--filter=main2.tf", + "--force", + // "--minimum-failure-severity=warning", + // "--color", + // "--no-color", + "--fix", + "--no-parallel-runners", + // "--max-workers=2", + // "--act-as-bundled-plugin", + "--act-as-worker", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var in Options + parser := flags.NewParser(&in, flags.HelpFlag) + _, err := parser.ParseArgs(test.in) + if err != nil { + t.Fatal(err) + } + + got := in.toWorkerCommands(test.workingDir) + + opt := cmpopts.SortSlices(func(a, b string) bool { return a < b }) + if diff := cmp.Diff(test.want, got, opt); diff != "" { + t.Fatal(diff) + } + + // Check if the output can be parsed + var out Options + parser = flags.NewParser(&out, flags.HelpFlag) + _, err = parser.ParseArgs(got) + if err != nil { + t.Fatal(err) + } + }) + } +} diff --git a/docs/user-guide/working-directory.md b/docs/user-guide/working-directory.md index 97b4af2de..abf1bb23a 100644 --- a/docs/user-guide/working-directory.md +++ b/docs/user-guide/working-directory.md @@ -22,6 +22,8 @@ The `--recursive` flag enables recursive inspection. This is the same as running $ tflint --recursive ``` +Recursive inspection is performed in parallel by default. The default parallelism is the number of CPUs. This can be controlled with `--max-workers`. + These flags are also valid for `--init` and `--version`. Recursive init is required when installing required plugins all at once: ```console diff --git a/formatter/checkstyle.go b/formatter/checkstyle.go index 50e3576c7..336b9c634 100644 --- a/formatter/checkstyle.go +++ b/formatter/checkstyle.go @@ -61,6 +61,6 @@ func (f *Formatter) checkstylePrint(issues tflint.Issues, appErr error, sources fmt.Fprint(f.Stdout, string(out)) if appErr != nil { - f.prettyPrintErrors(appErr, sources) + f.prettyPrintErrors(appErr, sources, false) } } diff --git a/formatter/compact.go b/formatter/compact.go index 9cab0d577..d665ccd69 100644 --- a/formatter/compact.go +++ b/formatter/compact.go @@ -26,25 +26,39 @@ func (f *Formatter) compactPrint(issues tflint.Issues, appErr error, sources map ) } - if appErr != nil { - var diags hcl.Diagnostics - if errors.As(appErr, &diags) { - for _, diag := range diags { - fmt.Fprintf( - f.Stdout, - "%s:%d:%d: %s - %s. %s\n", - diag.Subject.Filename, - diag.Subject.Start.Line, - diag.Subject.Start.Column, - fromHclSeverity(diag.Severity), - diag.Summary, - diag.Detail, - ) - } - - return + f.compactPrintErrors(appErr, sources) +} + +func (f *Formatter) compactPrintErrors(err error, sources map[string][]byte) { + if err == nil { + return + } + + // errors.Join + if errs, ok := err.(interface{ Unwrap() []error }); ok { + for _, err := range errs.Unwrap() { + f.compactPrintErrors(err, sources) } + return + } - f.prettyPrintErrors(appErr, sources) + // hcl.Diagnostics + var diags hcl.Diagnostics + if errors.As(err, &diags) { + for _, diag := range diags { + fmt.Fprintf( + f.Stdout, + "%s:%d:%d: %s - %s. %s\n", + diag.Subject.Filename, + diag.Subject.Start.Line, + diag.Subject.Start.Column, + fromHclSeverity(diag.Severity), + diag.Summary, + diag.Detail, + ) + } + return } + + f.prettyPrintErrors(err, sources, false) } diff --git a/formatter/compact_test.go b/formatter/compact_test.go index af00e9239..679c66286 100644 --- a/formatter/compact_test.go +++ b/formatter/compact_test.go @@ -51,6 +51,16 @@ test.tf:1:1: Error - test (test_rule) Error: hclDiags(`resource "foo" "bar" {`), Stdout: "main.tf:1:22: error - Unclosed configuration block. There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.\n", }, + { + Name: "joined errors", + Error: errors.Join( + errors.New("an error occurred"), + errors.New("failed"), + hclDiags(`resource "foo" "bar" {`), + ), + Stdout: "main.tf:1:22: error - Unclosed configuration block. There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.\n", + Stderr: "an error occurred\nfailed\n", + }, } for _, tc := range cases { diff --git a/formatter/formatter.go b/formatter/formatter.go index e8aed2ab5..3b1c60c8c 100644 --- a/formatter/formatter.go +++ b/formatter/formatter.go @@ -1,8 +1,10 @@ package formatter import ( + "errors" "fmt" "io" + "slices" hcl "github.com/hashicorp/hcl/v2" sdk "github.com/terraform-linters/tflint-plugin-sdk/tflint" @@ -16,6 +18,10 @@ type Formatter struct { Format string Fix bool NoColor bool + + // Errors occurred in parallel workers. + // Some formats do not output immediately, so they are saved here. + errInParallel error } // Print outputs the given issues and errors according to configured format @@ -38,6 +44,43 @@ func (f *Formatter) Print(issues tflint.Issues, err error, sources map[string][] } } +// PrintErrorParallel outputs an error occurred in parallel workers. +// Depending on the configured format, errors may not be output immediately. +// This function itself is called serially, so changes to f.errInParallel are safe. +func (f *Formatter) PrintErrorParallel(err error, sources map[string][]byte) { + if f.errInParallel == nil { + f.errInParallel = err + } else { + f.errInParallel = errors.Join(f.errInParallel, err) + } + + if slices.Contains([]string{"json", "checkstyle", "junit", "compact", "sarif"}, f.Format) { + // These formats require errors to be printed at the end, so do nothing here + return + } + + // Print errors immediately for other formats + f.prettyPrintErrors(err, sources, true) +} + +// PrintParallel outputs issues and errors in parallel workers. +// Errors stored with PrintErrorParallel are output, +// but in the default format they are output in real time, so they are ignored. +func (f *Formatter) PrintParallel(issues tflint.Issues, sources map[string][]byte) error { + if slices.Contains([]string{"json", "checkstyle", "junit", "compact", "sarif"}, f.Format) { + f.Print(issues, f.errInParallel, sources) + return f.errInParallel + } + + if f.errInParallel != nil { + // Do not print the errors since they are already printed in real time + return f.errInParallel + } + + f.Print(issues, nil, sources) + return nil +} + func toSeverity(lintType tflint.Severity) string { switch lintType { case sdk.ERROR: diff --git a/formatter/formatter_test.go b/formatter/formatter_test.go index 01f0e2e6d..5ad410b33 100644 --- a/formatter/formatter_test.go +++ b/formatter/formatter_test.go @@ -1,6 +1,13 @@ package formatter import ( + "bytes" + "errors" + "testing" + + "github.com/fatih/color" + "github.com/google/go-cmp/cmp" + hcl "github.com/hashicorp/hcl/v2" sdk "github.com/terraform-linters/tflint-plugin-sdk/tflint" "github.com/terraform-linters/tflint/tflint" ) @@ -22,3 +29,144 @@ func (r *testRule) Severity() tflint.Severity { func (r *testRule) Link() string { return "https://github.com" } + +func TestPrintErrorParallel(t *testing.T) { + // Disable color + color.NoColor = true + + tests := []struct { + name string + format string + err error + stderr string + }{ + { + name: "default", + format: "default", + err: errors.New("an error occurred\n\nfailed"), + stderr: `│ an error occurred + +failed +`, + }, + { + name: "JSON", + format: "json", + err: errors.New("an error occurred\n\nfailed"), + stderr: "", // no errors + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + stdout, stderr := new(bytes.Buffer), new(bytes.Buffer) + formatter := &Formatter{ + Stdout: stdout, + Stderr: stderr, + Format: test.format, + } + + formatter.PrintErrorParallel(test.err, map[string][]byte{}) + + if diff := cmp.Diff(test.stderr, stderr.String()); diff != "" { + t.Errorf(diff) + } + }) + } +} + +func TestPrintParallel(t *testing.T) { + tests := []struct { + name string + format string + before func(*Formatter) + stdout string + stderr string + error bool + }{ + { + name: "default with errors", + format: "default", + before: func(f *Formatter) { + f.PrintErrorParallel(errors.New("an error occurred"), map[string][]byte{}) + f.PrintErrorParallel(errors.New("failed"), map[string][]byte{}) + }, + stdout: "", // no issues + stderr: "", // no errors + error: true, + }, + { + name: "default without errors", + format: "default", + before: func(f *Formatter) {}, + stdout: `1 issue(s) found: + +Error: test (test_rule) + + on test.tf line 1: + (source code not available) + +Reference: https://github.com + +`, + }, + { + name: "JSON with errors", + format: "json", + before: func(f *Formatter) { + f.PrintErrorParallel(errors.New("an error occurred"), map[string][]byte{}) + f.PrintErrorParallel(errors.New("failed"), map[string][]byte{}) + }, + stdout: `{"issues":[{"rule":{"name":"test_rule","severity":"error","link":"https://github.com"},"message":"test","range":{"filename":"test.tf","start":{"line":1,"column":1},"end":{"line":1,"column":4}},"callers":[]}],"errors":[{"message":"an error occurred","severity":"error"},{"message":"failed","severity":"error"}]}`, + error: true, + }, + { + name: "JSON without errors", + format: "json", + before: func(f *Formatter) {}, + stdout: `{"issues":[{"rule":{"name":"test_rule","severity":"error","link":"https://github.com"},"message":"test","range":{"filename":"test.tf","start":{"line":1,"column":1},"end":{"line":1,"column":4}},"callers":[]}],"errors":[]}`, + }, + } + + issues := tflint.Issues{ + { + Rule: &testRule{}, + Message: "test", + Range: hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 4, Byte: 3}, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + formatter := &Formatter{ + Stdout: new(bytes.Buffer), + Stderr: new(bytes.Buffer), + Format: test.format, + } + test.before(formatter) + + stdout, stderr := new(bytes.Buffer), new(bytes.Buffer) + formatter.Stdout = stdout + formatter.Stderr = stderr + + err := formatter.PrintParallel(issues, map[string][]byte{}) + if err != nil && test.error == false { + t.Errorf("unexpected error: %s", err) + } + if err == nil && test.error == true { + t.Errorf("expected error but got nil") + } + + if diff := cmp.Diff(test.stdout, stdout.String()); diff != "" { + t.Errorf(diff) + } + if diff := cmp.Diff(test.stderr, stderr.String()); diff != "" { + t.Errorf(diff) + } + }) + } +} diff --git a/formatter/json.go b/formatter/json.go index 410bc5348..3519f884e 100644 --- a/formatter/json.go +++ b/formatter/json.go @@ -53,7 +53,7 @@ type JSONOutput struct { } func (f *Formatter) jsonPrint(issues tflint.Issues, appErr error) { - ret := &JSONOutput{Issues: make([]JSONIssue, len(issues)), Errors: []JSONError{}} + ret := &JSONOutput{Issues: make([]JSONIssue, len(issues)), Errors: f.jsonErrors(appErr)} for idx, issue := range issues.Sort() { ret.Issues[idx] = JSONIssue{ @@ -79,33 +79,48 @@ func (f *Formatter) jsonPrint(issues tflint.Issues, appErr error) { } } - if appErr != nil { - var diags hcl.Diagnostics - if errors.As(appErr, &diags) { - ret.Errors = make([]JSONError, len(diags)) - for idx, diag := range diags { - ret.Errors[idx] = JSONError{ - Severity: fromHclSeverity(diag.Severity), - Summary: diag.Summary, - Message: diag.Detail, - Range: &JSONRange{ - Filename: diag.Subject.Filename, - Start: JSONPos{Line: diag.Subject.Start.Line, Column: diag.Subject.Start.Column}, - End: JSONPos{Line: diag.Subject.End.Line, Column: diag.Subject.End.Column}, - }, - } - } - } else { - ret.Errors = []JSONError{{ - Severity: toSeverity(sdk.ERROR), - Message: appErr.Error(), - }} - } - } - out, err := json.Marshal(ret) if err != nil { fmt.Fprint(f.Stderr, err) } fmt.Fprint(f.Stdout, string(out)) } + +func (f *Formatter) jsonErrors(err error) []JSONError { + if err == nil { + return []JSONError{} + } + + // errors.Join + if errs, ok := err.(interface{ Unwrap() []error }); ok { + ret := []JSONError{} + for _, err := range errs.Unwrap() { + ret = append(ret, f.jsonErrors(err)...) + } + return ret + } + + // hcl.Diagnostics + var diags hcl.Diagnostics + if errors.As(err, &diags) { + ret := make([]JSONError, len(diags)) + for idx, diag := range diags { + ret[idx] = JSONError{ + Severity: fromHclSeverity(diag.Severity), + Summary: diag.Summary, + Message: diag.Detail, + Range: &JSONRange{ + Filename: diag.Subject.Filename, + Start: JSONPos{Line: diag.Subject.Start.Line, Column: diag.Subject.Start.Column}, + End: JSONPos{Line: diag.Subject.End.Line, Column: diag.Subject.End.Column}, + }, + } + } + return ret + } + + return []JSONError{{ + Severity: toSeverity(sdk.ERROR), + Message: err.Error(), + }} +} diff --git a/formatter/json_test.go b/formatter/json_test.go index c70dcf7ff..4461d5cdb 100644 --- a/formatter/json_test.go +++ b/formatter/json_test.go @@ -28,7 +28,7 @@ func Test_jsonPrint(t *testing.T) { Stdout: `{"issues":[],"errors":[{"message":"Failed to work; I don't feel like working","severity":"error"}]}`, }, { - Name: "error", + Name: "diagnostics", Error: fmt.Errorf( "babel fish confused; %w", hcl.Diagnostics{ @@ -46,6 +46,26 @@ func Test_jsonPrint(t *testing.T) { ), Stdout: `{"issues":[],"errors":[{"summary":"summary","message":"detail","severity":"warning","range":{"filename":"filename","start":{"line":1,"column":1},"end":{"line":5,"column":1}}}]}`, }, + { + Name: "joined errors", + Error: errors.Join( + errors.New("an error occurred"), + errors.New("failed"), + hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "summary", + Detail: "detail", + Subject: &hcl.Range{ + Filename: "filename", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 5, Column: 1, Byte: 4}, + }, + }, + }, + ), + Stdout: `{"issues":[],"errors":[{"message":"an error occurred","severity":"error"},{"message":"failed","severity":"error"},{"summary":"summary","message":"detail","severity":"warning","range":{"filename":"filename","start":{"line":1,"column":1},"end":{"line":5,"column":1}}}]}`, + }, } for _, tc := range cases { diff --git a/formatter/junit.go b/formatter/junit.go index 61b9f414f..dcbd8d23b 100644 --- a/formatter/junit.go +++ b/formatter/junit.go @@ -51,6 +51,6 @@ func (f *Formatter) junitPrint(issues tflint.Issues, appErr error, sources map[s fmt.Fprint(f.Stdout, string(out)) if appErr != nil { - f.prettyPrintErrors(appErr, sources) + f.prettyPrintErrors(appErr, sources, false) } } diff --git a/formatter/pretty.go b/formatter/pretty.go index 74b76a810..2a559817e 100644 --- a/formatter/pretty.go +++ b/formatter/pretty.go @@ -30,7 +30,7 @@ func (f *Formatter) prettyPrint(issues tflint.Issues, err error, sources map[str } if err != nil { - f.prettyPrintErrors(err, sources) + f.prettyPrintErrors(err, sources, false) } } @@ -102,18 +102,41 @@ func (f *Formatter) prettyPrintIssueWithSource(issue *tflint.Issue, sources map[ fmt.Fprint(f.Stdout, "\n") } -func (f *Formatter) prettyPrintErrors(err error, sources map[string][]byte) { +func (f *Formatter) prettyPrintErrors(err error, sources map[string][]byte, withIndent bool) { + if err == nil { + return + } + + // errors.Join + if errs, ok := err.(interface{ Unwrap() []error }); ok { + for _, err := range errs.Unwrap() { + f.prettyPrintErrors(err, sources, withIndent) + } + return + } + + // hcl.Diagnostics var diags hcl.Diagnostics if errors.As(err, &diags) { fmt.Fprintf(f.Stderr, "%s:\n\n", err) writer := hcl.NewDiagnosticTextWriter(f.Stderr, parseSources(sources), 0, !f.NoColor) _ = writer.WriteDiagnostics(diags) + return + } + + if withIndent { + fmt.Fprintf(f.Stderr, "%s %s\n", colorError("│"), err) } else { fmt.Fprintf(f.Stderr, "%s\n", err) } } +// PrettyPrintStderr outputs the given output to stderr with an indent. +func (f *Formatter) PrettyPrintStderr(output string) { + fmt.Fprintf(f.Stderr, "%s %s\n", colorWarning("│"), output) +} + func parseSources(sources map[string][]byte) map[string]*hcl.File { ret := map[string]*hcl.File{} parser := hclparse.NewParser() diff --git a/formatter/pretty_test.go b/formatter/pretty_test.go index 1c358dd01..dbf13dba7 100644 --- a/formatter/pretty_test.go +++ b/formatter/pretty_test.go @@ -15,6 +15,10 @@ func Test_prettyPrint(t *testing.T) { // Disable color color.NoColor = true + warningColor := "\x1b[33m" + highlightColor := "\x1b[1;4m" + resetColor := "\x1b[0m" + cases := []struct { Name string Issues tflint.Issues @@ -187,6 +191,70 @@ Reference: https://github.com Error: fmt.Errorf("Failed to work; %w", errors.New("I don't feel like working")), Stderr: "Failed to work; I don't feel like working\n", }, + { + Name: "diagnostics", + Issues: tflint.Issues{}, + Error: hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "summary", + Detail: "detail", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 4, Byte: 3}, + }, + }, + }, + Sources: map[string][]byte{ + "test.tf": []byte("foo = 1"), + }, + Stderr: fmt.Sprintf(`test.tf:1,1-4: summary; detail: + +%sWarning%s: summary + + on test.tf line 1: + 1: %sfoo%s = 1 + +detail + +`, warningColor, resetColor, highlightColor, resetColor), + }, + { + Name: "joined errors", + Issues: tflint.Issues{}, + Error: errors.Join( + errors.New("an error occurred"), + errors.New("failed"), + hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "summary", + Detail: "detail", + Subject: &hcl.Range{ + Filename: "test.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 1, Column: 4, Byte: 3}, + }, + }, + }, + ), + Sources: map[string][]byte{ + "test.tf": []byte("foo = 1"), + }, + Stderr: fmt.Sprintf(`an error occurred +failed +test.tf:1,1-4: summary; detail: + +%sWarning%s: summary + + on test.tf line 1: + 1: %sfoo%s = 1 + +detail + +`, warningColor, resetColor, highlightColor, resetColor), + }, } for _, tc := range cases { diff --git a/formatter/sarif.go b/formatter/sarif.go index 9bf95c9e3..75c7a646e 100644 --- a/formatter/sarif.go +++ b/formatter/sarif.go @@ -68,37 +68,52 @@ func (f *Formatter) sarifPrint(issues tflint.Issues, appErr error) { errRun.Tool.Driver.Version = &version report.AddRun(errRun) - - if appErr != nil { - var diags hcl.Diagnostics - if errors.As(appErr, &diags) { - for _, diag := range diags { - location := sarif.NewPhysicalLocation(). - WithArtifactLocation(sarif.NewSimpleArtifactLocation(filepath.ToSlash(diag.Subject.Filename))). - WithRegion( - sarif.NewRegion(). - WithByteOffset(diag.Subject.Start.Byte). - WithByteLength(diag.Subject.End.Byte - diag.Subject.Start.Byte). - WithStartLine(diag.Subject.Start.Line). - WithStartColumn(diag.Subject.Start.Column). - WithEndLine(diag.Subject.End.Line). - WithEndColumn(diag.Subject.End.Column), - ) - - errRun.AddResult(diag.Summary). - WithLevel(fromHclSeverity(diag.Severity)). - WithLocation(sarif.NewLocationWithPhysicalLocation(location)). - WithMessage(sarif.NewTextMessage(diag.Detail)) - } - } else { - errRun.AddResult("application_error"). - WithLevel("error"). - WithMessage(sarif.NewTextMessage(appErr.Error())) - } - } + f.sarifAddErrors(errRun, appErr) stdoutErr := report.PrettyWrite(f.Stdout) if stdoutErr != nil { panic(stdoutErr) } } + +func (f *Formatter) sarifAddErrors(errRun *sarif.Run, err error) { + if err == nil { + return + } + + // errors.Join + if errs, ok := err.(interface{ Unwrap() []error }); ok { + for _, err := range errs.Unwrap() { + f.sarifAddErrors(errRun, err) + } + return + } + + // hcl.Diagnostics + var diags hcl.Diagnostics + if errors.As(err, &diags) { + for _, diag := range diags { + location := sarif.NewPhysicalLocation(). + WithArtifactLocation(sarif.NewSimpleArtifactLocation(filepath.ToSlash(diag.Subject.Filename))). + WithRegion( + sarif.NewRegion(). + WithByteOffset(diag.Subject.Start.Byte). + WithByteLength(diag.Subject.End.Byte - diag.Subject.Start.Byte). + WithStartLine(diag.Subject.Start.Line). + WithStartColumn(diag.Subject.Start.Column). + WithEndLine(diag.Subject.End.Line). + WithEndColumn(diag.Subject.End.Column), + ) + + errRun.AddResult(diag.Summary). + WithLevel(fromHclSeverity(diag.Severity)). + WithLocation(sarif.NewLocationWithPhysicalLocation(location)). + WithMessage(sarif.NewTextMessage(diag.Detail)) + } + return + } + + errRun.AddResult("application_error"). + WithLevel("error"). + WithMessage(sarif.NewTextMessage(err.Error())) +} diff --git a/formatter/sarif_test.go b/formatter/sarif_test.go index d0c6ac30a..4291453f2 100644 --- a/formatter/sarif_test.go +++ b/formatter/sarif_test.go @@ -479,6 +479,90 @@ func Test_sarifPrint(t *testing.T) { ] } ] +}`, tflint.Version, tflint.Version), + }, + { + Name: "joined errors", + Error: errors.Join( + errors.New("an error occurred"), + errors.New("failed"), + hcl.Diagnostics{ + &hcl.Diagnostic{ + Severity: hcl.DiagWarning, + Summary: "summary", + Detail: "detail", + Subject: &hcl.Range{ + Filename: "filename", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 0}, + End: hcl.Pos{Line: 5, Column: 1, Byte: 4}, + }, + }, + }, + ), + Stdout: fmt.Sprintf(`{ + "version": "2.1.0", + "$schema": "https://json.schemastore.org/sarif-2.1.0-rtm.5.json", + "runs": [ + { + "tool": { + "driver": { + "name": "tflint", + "version": "%s", + "informationUri": "https://github.com/terraform-linters/tflint" + } + }, + "results": [] + }, + { + "tool": { + "driver": { + "name": "tflint-errors", + "version": "%s", + "informationUri": "https://github.com/terraform-linters/tflint" + } + }, + "results": [ + { + "ruleId": "application_error", + "level": "error", + "message": { + "text": "an error occurred" + } + }, + { + "ruleId": "application_error", + "level": "error", + "message": { + "text": "failed" + } + }, + { + "ruleId": "summary", + "level": "warning", + "message": { + "text": "detail" + }, + "locations": [ + { + "physicalLocation": { + "artifactLocation": { + "uri": "filename" + }, + "region": { + "startLine": 1, + "startColumn": 1, + "endLine": 5, + "endColumn": 1, + "byteOffset": 0, + "byteLength": 4 + } + } + } + ] + } + ] + } + ] }`, tflint.Version, tflint.Version), }, } diff --git a/integrationtest/cli/cli_test.go b/integrationtest/cli/cli_test.go index d1cbcd7c7..1cff3633f 100644 --- a/integrationtest/cli/cli_test.go +++ b/integrationtest/cli/cli_test.go @@ -366,32 +366,11 @@ func TestIntegration(t *testing.T) { stdout: fmt.Sprintf("%s (aws_instance_example_type)", color.New(color.Bold).Sprint("instance type is m5.2xlarge")), }, { - name: "--recursive and file argument", - command: "./tflint --recursive main.tf", - dir: "chdir", - status: cmd.ExitCodeError, - stderr: `Command line arguments support was dropped in v0.47. Use --chdir or --filter instead.`, - }, - { - name: "--recursive and directory argument", - command: "./tflint --recursive subdir", - dir: "chdir", - status: cmd.ExitCodeError, - stderr: `Command line arguments support was dropped in v0.47. Use --chdir or --filter instead.`, - }, - { - name: "--recursive and the current directory argument", - command: "./tflint --recursive .", - dir: "chdir", + name: "invalid max workers", + command: "./tflint --max-workers=0", + dir: "no_issues", status: cmd.ExitCodeError, - stderr: `Command line arguments support was dropped in v0.47. Use --chdir or --filter instead.`, - }, - { - name: "--recursive and --filter", - command: "./tflint --recursive --filter=main.tf", - dir: "chdir", - status: cmd.ExitCodeIssuesFound, - stdout: fmt.Sprintf("%s (aws_instance_example_type)", color.New(color.Bold).Sprint("instance type is m5.2xlarge")), + stderr: `Max workers should be greater than 0`, }, } diff --git a/integrationtest/inspection/inspection_test.go b/integrationtest/inspection/inspection_test.go index d99673efb..259cf99cc 100644 --- a/integrationtest/inspection/inspection_test.go +++ b/integrationtest/inspection/inspection_test.go @@ -205,11 +205,6 @@ func TestIntegration(t *testing.T) { Command: "tflint --chdir dir --var-file from_cli.tfvars --format json", Dir: "chdir", }, - { - Name: "recursive", - Command: "tflint --recursive --format json", - Dir: "recursive", - }, } // Disable the bundled plugin because the `os.Executable()` is go(1) in the tests diff --git a/integrationtest/inspection/recursive/subdir1/.tflint.hcl b/integrationtest/inspection/recursive/subdir1/.tflint.hcl deleted file mode 100644 index e19f589dd..000000000 --- a/integrationtest/inspection/recursive/subdir1/.tflint.hcl +++ /dev/null @@ -1,3 +0,0 @@ -plugin "testing" { - enabled = true -} diff --git a/integrationtest/inspection/recursive/subdir2/.tflint.hcl b/integrationtest/inspection/recursive/subdir2/.tflint.hcl deleted file mode 100644 index e19f589dd..000000000 --- a/integrationtest/inspection/recursive/subdir2/.tflint.hcl +++ /dev/null @@ -1,3 +0,0 @@ -plugin "testing" { - enabled = true -} diff --git a/integrationtest/inspection/recursive/result.json b/integrationtest/recursive/basic/result.json similarity index 100% rename from integrationtest/inspection/recursive/result.json rename to integrationtest/recursive/basic/result.json diff --git a/integrationtest/inspection/recursive/result_windows.json b/integrationtest/recursive/basic/result_windows.json similarity index 100% rename from integrationtest/inspection/recursive/result_windows.json rename to integrationtest/recursive/basic/result_windows.json diff --git a/integrationtest/recursive/basic/subdir1/.tflint.hcl b/integrationtest/recursive/basic/subdir1/.tflint.hcl new file mode 100644 index 000000000..8a0ebc98c --- /dev/null +++ b/integrationtest/recursive/basic/subdir1/.tflint.hcl @@ -0,0 +1,7 @@ +plugin "terraform" { + enabled = false +} + +plugin "testing" { + enabled = true +} diff --git a/integrationtest/inspection/recursive/subdir1/main.tf b/integrationtest/recursive/basic/subdir1/main.tf similarity index 100% rename from integrationtest/inspection/recursive/subdir1/main.tf rename to integrationtest/recursive/basic/subdir1/main.tf diff --git a/integrationtest/recursive/basic/subdir2/.tflint.hcl b/integrationtest/recursive/basic/subdir2/.tflint.hcl new file mode 100644 index 000000000..8a0ebc98c --- /dev/null +++ b/integrationtest/recursive/basic/subdir2/.tflint.hcl @@ -0,0 +1,7 @@ +plugin "terraform" { + enabled = false +} + +plugin "testing" { + enabled = true +} diff --git a/integrationtest/inspection/recursive/subdir2/main.tf b/integrationtest/recursive/basic/subdir2/main.tf similarity index 100% rename from integrationtest/inspection/recursive/subdir2/main.tf rename to integrationtest/recursive/basic/subdir2/main.tf diff --git a/integrationtest/recursive/errors/result.json b/integrationtest/recursive/errors/result.json new file mode 100644 index 000000000..0f10b827e --- /dev/null +++ b/integrationtest/recursive/errors/result.json @@ -0,0 +1,13 @@ +{ + "issues": [], + "errors": [ + { + "message": "Failed to run in subdir2; exit status 1\n\nFailed to load configurations; subdir2/main.tf:2,1-2: Argument or block definition required; An argument or block definition is required here.:\n\n\u001b[31mError\u001b[0m: Argument or block definition required\n\n on subdir2/main.tf line 2:\n 2: \u001b[1;4m}\u001b[0m\n\nAn argument or block definition is required here.\n\n", + "severity": "error" + }, + { + "message": "Failed to run in subdir1; exit status 1\n\nFailed to load configurations; subdir1/main.tf:1,31-32: Unclosed configuration block; There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.:\n\n\u001b[31mError\u001b[0m: Unclosed configuration block\n\n on subdir1/main.tf line 1, in resource \"aws_instance\" \"foo\":\n 1: resource \"aws_instance\" \"foo\" \u001b[1;4m{\u001b[0m\n\nThere is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.\n\n", + "severity": "error" + } + ] +} diff --git a/integrationtest/recursive/errors/result_windows.json b/integrationtest/recursive/errors/result_windows.json new file mode 100644 index 000000000..79da95ab3 --- /dev/null +++ b/integrationtest/recursive/errors/result_windows.json @@ -0,0 +1,13 @@ +{ + "issues": [], + "errors": [ + { + "message": "Failed to run in subdir2; exit status 1\n\nFailed to load configurations; subdir2\\main.tf:2,1-2: Argument or block definition required; An argument or block definition is required here.:\n\n\u001b[31mError\u001b[0m: Argument or block definition required\n\n on subdir2\\main.tf line 2:\n 2: \u001b[1;4m}\u001b[0m\n\nAn argument or block definition is required here.\n\n", + "severity": "error" + }, + { + "message": "Failed to run in subdir1; exit status 1\n\nFailed to load configurations; subdir1\\main.tf:1,31-32: Unclosed configuration block; There is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.:\n\n\u001b[31mError\u001b[0m: Unclosed configuration block\n\n on subdir1\\main.tf line 1, in resource \"aws_instance\" \"foo\":\n 1: resource \"aws_instance\" \"foo\" \u001b[1;4m{\u001b[0m\n\nThere is no closing brace for this block before the end of the file. This may be caused by incorrect brace nesting elsewhere in this file.\n\n", + "severity": "error" + } + ] +} diff --git a/integrationtest/recursive/errors/subdir1/.tflint.hcl b/integrationtest/recursive/errors/subdir1/.tflint.hcl new file mode 100644 index 000000000..8a0ebc98c --- /dev/null +++ b/integrationtest/recursive/errors/subdir1/.tflint.hcl @@ -0,0 +1,7 @@ +plugin "terraform" { + enabled = false +} + +plugin "testing" { + enabled = true +} diff --git a/integrationtest/recursive/errors/subdir1/main.tf b/integrationtest/recursive/errors/subdir1/main.tf new file mode 100644 index 000000000..b946a9050 --- /dev/null +++ b/integrationtest/recursive/errors/subdir1/main.tf @@ -0,0 +1 @@ +resource "aws_instance" "foo" { diff --git a/integrationtest/recursive/errors/subdir2/.tflint.hcl b/integrationtest/recursive/errors/subdir2/.tflint.hcl new file mode 100644 index 000000000..8a0ebc98c --- /dev/null +++ b/integrationtest/recursive/errors/subdir2/.tflint.hcl @@ -0,0 +1,7 @@ +plugin "terraform" { + enabled = false +} + +plugin "testing" { + enabled = true +} diff --git a/integrationtest/recursive/errors/subdir2/main.tf b/integrationtest/recursive/errors/subdir2/main.tf new file mode 100644 index 000000000..506ce22e2 --- /dev/null +++ b/integrationtest/recursive/errors/subdir2/main.tf @@ -0,0 +1,2 @@ + instance_type = "t2.micro" +} diff --git a/integrationtest/recursive/filter/result.json b/integrationtest/recursive/filter/result.json new file mode 100644 index 000000000..4af767e98 --- /dev/null +++ b/integrationtest/recursive/filter/result.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t2.micro", + "range": { + "filename": "subdir2/main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 29 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/recursive/filter/result_windows.json b/integrationtest/recursive/filter/result_windows.json new file mode 100644 index 000000000..a44fe035c --- /dev/null +++ b/integrationtest/recursive/filter/result_windows.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t2.micro", + "range": { + "filename": "subdir2\\main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 29 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integrationtest/recursive/filter/subdir1/.tflint.hcl b/integrationtest/recursive/filter/subdir1/.tflint.hcl new file mode 100644 index 000000000..8a0ebc98c --- /dev/null +++ b/integrationtest/recursive/filter/subdir1/.tflint.hcl @@ -0,0 +1,7 @@ +plugin "terraform" { + enabled = false +} + +plugin "testing" { + enabled = true +} diff --git a/integrationtest/recursive/filter/subdir1/resource.tf b/integrationtest/recursive/filter/subdir1/resource.tf new file mode 100644 index 000000000..43383052f --- /dev/null +++ b/integrationtest/recursive/filter/subdir1/resource.tf @@ -0,0 +1,3 @@ +resource "aws_instance" "foo" { + instance_type = "t2.micro" +} diff --git a/integrationtest/recursive/filter/subdir2/.tflint.hcl b/integrationtest/recursive/filter/subdir2/.tflint.hcl new file mode 100644 index 000000000..8a0ebc98c --- /dev/null +++ b/integrationtest/recursive/filter/subdir2/.tflint.hcl @@ -0,0 +1,7 @@ +plugin "terraform" { + enabled = false +} + +plugin "testing" { + enabled = true +} diff --git a/integrationtest/recursive/filter/subdir2/main.tf b/integrationtest/recursive/filter/subdir2/main.tf new file mode 100644 index 000000000..43383052f --- /dev/null +++ b/integrationtest/recursive/filter/subdir2/main.tf @@ -0,0 +1,3 @@ +resource "aws_instance" "foo" { + instance_type = "t2.micro" +} diff --git a/integrationtest/recursive/recursive_test.go b/integrationtest/recursive/recursive_test.go new file mode 100644 index 000000000..77d0f065c --- /dev/null +++ b/integrationtest/recursive/recursive_test.go @@ -0,0 +1,115 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/terraform-linters/tflint/formatter" +) + +func TestIntegration(t *testing.T) { + tests := []struct { + name string + command string + dir string + error bool + ignoreOrder bool + }{ + { + name: "recursive", + command: "tflint --recursive --format json --force", + dir: "basic", + }, + { + name: "recursive + filter", + command: "tflint --recursive --filter=main.tf --format json --force", + dir: "filter", + }, + { + name: "recursive with errors", + command: "tflint --recursive --format json --force", + dir: "errors", + error: true, + ignoreOrder: true, + }, + } + + dir, _ := os.Getwd() + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + testDir := filepath.Join(dir, test.dir) + + t.Cleanup(func() { + if err := os.Chdir(dir); err != nil { + t.Fatal(err) + } + }) + + if err := os.Chdir(testDir); err != nil { + t.Fatal(err) + } + + args := strings.Split(test.command, " ") + var cmd *exec.Cmd + if runtime.GOOS == "windows" { + cmd = exec.Command("tflint.exe", args[1:]...) + } else { + cmd = exec.Command("tflint", args[1:]...) + } + outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) + cmd.Stdout = outStream + cmd.Stderr = errStream + + if err := cmd.Run(); err != nil && !test.error { + t.Fatalf("Failed to exec command: %s", err) + } + + var b []byte + var err error + if runtime.GOOS == "windows" && IsWindowsResultExist() { + b, err = os.ReadFile(filepath.Join(testDir, "result_windows.json")) + } else { + b, err = os.ReadFile(filepath.Join(testDir, "result.json")) + } + if err != nil { + t.Fatal(err) + } + + var expected *formatter.JSONOutput + if err := json.Unmarshal(b, &expected); err != nil { + t.Fatal(err) + } + + var got *formatter.JSONOutput + if err := json.Unmarshal(outStream.Bytes(), &got); err != nil { + t.Fatal(err) + } + + opts := []cmp.Option{ + cmpopts.IgnoreFields(formatter.JSONRule{}, "Link"), + } + if test.ignoreOrder { + opts = append(opts, cmpopts.SortSlices(func(a, b formatter.JSONError) bool { + return a.Message > b.Message + })) + } + if diff := cmp.Diff(got, expected, opts...); diff != "" { + t.Error(diff) + } + }) + } +} + +func IsWindowsResultExist() bool { + _, err := os.Stat("result_windows.json") + return !os.IsNotExist(err) +} diff --git a/tflint/issue.go b/tflint/issue.go index 49a767eae..00d4b4055 100644 --- a/tflint/issue.go +++ b/tflint/issue.go @@ -1,6 +1,7 @@ package tflint import ( + "encoding/json" "fmt" "sort" @@ -80,3 +81,56 @@ func (issues Issues) Sort() Issues { }) return issues } + +type issue struct { + Rule *rule `json:"rule"` + Message string `json:"message"` + Range hcl.Range `json:"range"` + Fixable bool `json:"fixable"` + Callers []hcl.Range `json:"callers"` + Source []byte `json:"source"` +} + +type rule struct { + RawName string `json:"name"` + RawSeverity Severity `json:"severity"` + RawLink string `json:"link"` +} + +var _ Rule = (*rule)(nil) + +func (r *rule) Name() string { return r.RawName } +func (r *rule) Severity() Severity { return r.RawSeverity } +func (r *rule) Link() string { return r.RawLink } + +func (i *Issue) MarshalJSON() ([]byte, error) { + return json.Marshal(issue{ + Rule: &rule{ + RawName: i.Rule.Name(), + RawSeverity: i.Rule.Severity(), + RawLink: i.Rule.Link(), + }, + Message: i.Message, + Range: i.Range, + Fixable: i.Fixable, + Callers: i.Callers, + Source: i.Source, + }) +} + +func (i *Issue) UnmarshalJSON(data []byte) error { + var out issue + err := json.Unmarshal(data, &out) + if err != nil { + return err + } + + i.Rule = out.Rule + i.Message = out.Message + i.Range = out.Range + i.Fixable = out.Fixable + i.Callers = out.Callers + i.Source = out.Source + + return nil +} diff --git a/tflint/issue_test.go b/tflint/issue_test.go index c44e173ef..f4bb107a6 100644 --- a/tflint/issue_test.go +++ b/tflint/issue_test.go @@ -1,6 +1,7 @@ package tflint import ( + "encoding/json" "fmt" "strings" "testing" @@ -226,3 +227,60 @@ func Test_Sort(t *testing.T) { t.Fatalf("Failed: diff=%s", cmp.Diff(got, expected)) } } + +func TestMarshalJSON(t *testing.T) { + tests := []struct { + name string + issues Issues + }{ + { + name: "no issues", + issues: Issues{}, + }, + { + name: "issues", + issues: Issues{ + { + Rule: &testRule{}, + Message: "test", + Range: hcl.Range{ + Filename: "main.tf", + Start: hcl.Pos{Line: 1, Column: 1, Byte: 1}, + End: hcl.Pos{Line: 1, Column: 2, Byte: 2}, + }, + Fixable: true, + Callers: []hcl.Range{ + { + Filename: "caller.tf", + Start: hcl.Pos{Line: 2, Column: 2, Byte: 2}, + End: hcl.Pos{Line: 2, Column: 3, Byte: 3}, + }, + }, + Source: []byte(`resource "aws_instance" "web" {}`), + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + out, err := json.Marshal(test.issues) + if err != nil { + t.Fatal(err) + } + + var got Issues + err = json.Unmarshal(out, &got) + if err != nil { + t.Fatal(err) + } + + opt := cmp.Comparer(func(x, y Rule) bool { + return x.Name() == y.Name() && x.Severity() == y.Severity() && x.Link() == y.Link() + }) + if diff := cmp.Diff(got, test.issues, opt); diff != "" { + t.Errorf("diff=%s", diff) + } + }) + } +}