From 75aa36e5a1270a62efd5ff5496e9b79b86c6357b Mon Sep 17 00:00:00 2001 From: Kazuma Watanabe Date: Wed, 21 Dec 2022 00:15:06 +0900 Subject: [PATCH] Add --recursive option (#1622) * Add --recursive option * Apply suggestions from code review Co-authored-by: Ben Drucker * Use defer to return to the original working directory Co-authored-by: Ben Drucker --- README.md | 11 +- cmd/cli.go | 21 +- cmd/init.go | 4 + cmd/inspect.go | 269 ++++++++++++------ cmd/langserver.go | 5 + cmd/option.go | 1 + cmd/version.go | 4 + docs/user-guide/README.md | 1 + docs/user-guide/config.md | 15 +- docs/user-guide/working-directory.md | 27 ++ integrationtest/cli/cli_test.go | 12 +- integrationtest/init/init_test.go | 5 +- integrationtest/inspection/inspection_test.go | 10 +- .../inspection/recursive/result.json | 45 +++ .../inspection/recursive/result_windows.json | 45 +++ .../inspection/recursive/subdir1/.tflint.hcl | 3 + .../inspection/recursive/subdir1/main.tf | 3 + .../inspection/recursive/subdir2/.tflint.hcl | 3 + .../inspection/recursive/subdir2/main.tf | 3 + main.go | 6 +- terraform/loader.go | 4 + terraform/parser.go | 8 + terraform/parser_test.go | 74 +++++ 23 files changed, 468 insertions(+), 111 deletions(-) create mode 100644 docs/user-guide/working-directory.md create mode 100644 integrationtest/inspection/recursive/result.json create mode 100644 integrationtest/inspection/recursive/result_windows.json create mode 100644 integrationtest/inspection/recursive/subdir1/.tflint.hcl create mode 100644 integrationtest/inspection/recursive/subdir1/main.tf create mode 100644 integrationtest/inspection/recursive/subdir2/.tflint.hcl create mode 100644 integrationtest/inspection/recursive/subdir2/main.tf diff --git a/README.md b/README.md index 2a24f39f2..d1586d14b 100644 --- a/README.md +++ b/README.md @@ -141,6 +141,7 @@ Application Options: --module Inspect modules --chdir=DIR Switch to a different working directory before running inspection --force Return zero exit status even if issues found + --recursive Inspect directories recursively --color Enable colorized output --no-color Disable colorized output @@ -150,16 +151,6 @@ Help Options: See [User Guide](docs/user-guide) for details. -## FAQ - -### Does TFLint check modules recursively? -No. TFLint always checks only the current root module (no recursive check). However, you can check calling child modules based on module arguments by enabling [Module Inspection](docs/user-guide/module-inspection.md). This allows you to check that you are not passing illegal values to the module. - -Note that if you want to recursively inspect local modules, you need to run them in each directory. This is a limitation that occurs because Terraform always works for one directory. TFLint tries to emulate Terraform's semantics, so cannot perform recursive inspection. - -### Do I need to install Terraform for TFLint to work? -No. TFLint works as a single binary because Terraform is embedded as a library. Note that this means that the version of Terraform used is determined for each TFLint version. See also [Compatibility with Terraform](docs/user-guide/compatibility.md). - ## Debugging If you don't get the expected behavior, you can see the detailed logs when running with `TFLINT_LOG` environment variable. diff --git a/cmd/cli.go b/cmd/cli.go index 19b776a32..467ee9281 100644 --- a/cmd/cli.go +++ b/cmd/cli.go @@ -29,16 +29,25 @@ type CLI struct { // outStream and errStream are the stdout and stderr // to write message from the CLI. outStream, errStream io.Writer - loader *terraform.Loader - formatter *formatter.Formatter + originalWorkingDir string + sources map[string][]byte + + // fields for each module + config *tflint.Config + loader *terraform.Loader + formatter *formatter.Formatter } // NewCLI returns new CLI initialized by input streams -func NewCLI(outStream io.Writer, errStream io.Writer) *CLI { +func NewCLI(outStream io.Writer, errStream io.Writer) (*CLI, error) { + wd, err := os.Getwd() + return &CLI{ - outStream: outStream, - errStream: errStream, - } + outStream: outStream, + errStream: errStream, + originalWorkingDir: wd, + sources: map[string][]byte{}, + }, err } // Run invokes the CLI with the given arguments. diff --git a/cmd/init.go b/cmd/init.go index eb4982ba6..d5c33b67f 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -15,6 +15,10 @@ func (cli *CLI) init(opts Options) int { fmt.Fprintf(cli.errStream, "Cannot use --chdir with --init\n") return ExitCodeError } + if opts.Recursive { + fmt.Fprintf(cli.errStream, "Cannot use --recursive with --init\n") + return ExitCodeError + } cfg, err := tflint.LoadConfig(afero.Afero{Fs: afero.NewOsFs()}, opts.Config) if err != nil { diff --git a/cmd/inspect.go b/cmd/inspect.go index 7ebfbb125..c63434fef 100644 --- a/cmd/inspect.go +++ b/cmd/inspect.go @@ -3,6 +3,7 @@ package cmd import ( "fmt" "os" + "path/filepath" "strings" "github.com/hashicorp/hcl/v2" @@ -15,112 +16,132 @@ import ( "google.golang.org/grpc/status" ) -func (cli *CLI) inspect(opts Options, dir string, filterFiles []string) int { - // Switch to a different working directory - originalWd, err := os.Getwd() - if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to determine current working directory; %w", err), map[string][]byte{}) +func (cli *CLI) inspect(opts Options, targetDir string, filterFiles []string) int { + // Respect the "--format" flag until a config is loaded + cli.formatter.Format = opts.Format + + if opts.Chdir != "" && targetDir != "." { + cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Cannot use --chdir and directory argument at the same time"), map[string][]byte{}) + return ExitCodeError + } + if opts.Recursive && (targetDir != "." || len(filterFiles) > 0) { + cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Cannot use --recursive and arguments at the same time"), map[string][]byte{}) return ExitCodeError } - if opts.Chdir != "" { - if dir != "." { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Cannot use --chdir and directory argument at the same time"), map[string][]byte{}) + + workingDirs := []string{} + + if opts.Recursive { + // NOTE: The target directory is always the current directory in recursive mode + err := filepath.WalkDir(".", func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if !d.IsDir() { + return nil + } + // hidden directories are skipped + if path != "." && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } + + workingDirs = append(workingDirs, path) + return nil + }) + if err != nil { + cli.formatter.Print(tflint.Issues{}, err, map[string][]byte{}) return ExitCodeError } + } else { + if opts.Chdir == "" { + workingDirs = []string{"."} + } else { + workingDirs = []string{opts.Chdir} + } + } - err := os.Chdir(opts.Chdir) + issues := tflint.Issues{} + + for _, wd := range workingDirs { + err := cli.withinChangedDir(wd, func() error { + moduleIssues, err := cli.inspectModule(opts, targetDir, filterFiles) + if err != nil { + return err + } + issues = append(issues, moduleIssues...) + return nil + }) if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to switch to a different working directory; %w", err), map[string][]byte{}) + 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 + force = opts.Force + } else { + cli.formatter.Format = cli.config.Format + force = cli.config.Force + } + + cli.formatter.Print(issues, nil, cli.sources) + + if len(issues) > 0 && !force { + return ExitCodeIssuesFound + } + + return ExitCodeOK +} + +func (cli *CLI) inspectModule(opts Options, dir string, filterFiles []string) (tflint.Issues, error) { + issues := tflint.Issues{} + var err error + // Setup config - cfg, err := tflint.LoadConfig(afero.Afero{Fs: afero.NewOsFs()}, opts.Config) + cli.config, err = tflint.LoadConfig(afero.Afero{Fs: afero.NewOsFs()}, opts.Config) if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to load TFLint config; %w", err), map[string][]byte{}) - return ExitCodeError + return tflint.Issues{}, fmt.Errorf("Failed to load TFLint config; %w", err) } // tflint-plugin-sdk v0.13+ doesn't need to disable rules config when enabling the only option. // This is for the backward compatibility. if len(opts.Only) > 0 { - for _, rule := range cfg.Rules { + for _, rule := range cli.config.Rules { rule.Enabled = false } } - cfg.Merge(opts.toConfig()) - cli.formatter.Format = cfg.Format + cli.config.Merge(opts.toConfig()) // Setup loader - cli.loader, err = terraform.NewLoader(afero.Afero{Fs: afero.NewOsFs()}, originalWd) + cli.loader, err = terraform.NewLoader(afero.Afero{Fs: afero.NewOsFs()}, cli.originalWorkingDir) if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to prepare loading; %w", err), map[string][]byte{}) - return ExitCodeError + return tflint.Issues{}, fmt.Errorf("Failed to prepare loading; %w", err) } - - // Setup runners - runners, appErr := cli.setupRunners(opts, cfg, originalWd, dir) - if appErr != nil { - cli.formatter.Print(tflint.Issues{}, appErr, cli.loader.Sources()) - return ExitCodeError + if opts.Recursive && !cli.loader.IsConfigDir(dir) { + // Ignore non-module directories in recursive mode + return tflint.Issues{}, nil } - rootRunner := runners[len(runners)-1] - // Lookup plugins and validation - rulesetPlugin, err := plugin.Discovery(cfg) + // Setup runners + runners, err := cli.setupRunners(opts, dir) if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to initialize plugins; %w", err), cli.loader.Sources()) - return ExitCodeError + return tflint.Issues{}, err } - defer rulesetPlugin.Clean() - - rulesets := []tflint.RuleSet{} - config := cfg.ToPluginConfig() - for name, ruleset := range rulesetPlugin.RuleSets { - constraints, err := ruleset.VersionConstraints() - if err != nil { - if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { - // VersionConstraints endpoint is available in tflint-plugin-sdk v0.14+. - // Skip verification if not available. - } else { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to get TFLint version constraints to `%s` plugin; %w", name, err), cli.loader.Sources()) - return ExitCodeError - } - } - if !constraints.Check(tflint.Version) { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to satisfy version constraints; tflint-ruleset-%s requires %s, but TFLint version is %s", name, constraints, tflint.Version), cli.loader.Sources()) - return ExitCodeError - } - - if err := ruleset.ApplyGlobalConfig(config); err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to apply global config to `%s` plugin; %w", name, err), cli.loader.Sources()) - return ExitCodeError - } - configSchema, err := ruleset.ConfigSchema() - if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to fetch config schema from `%s` plugin; %w", name, err), cli.loader.Sources()) - return ExitCodeError - } - content := &hclext.BodyContent{} - if plugin, exists := cfg.Plugins[name]; exists { - var diags hcl.Diagnostics - content, diags = plugin.Content(configSchema) - if diags.HasErrors() { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to parse `%s` plugin config; %w", name, diags), cli.loader.Sources()) - return ExitCodeError - } - } - err = ruleset.ApplyConfig(content, cfg.Sources()) - if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to apply config to `%s` plugin; %w", name, err), cli.loader.Sources()) - return ExitCodeError - } + rootRunner := runners[len(runners)-1] - rulesets = append(rulesets, ruleset) + // Launch plugin processes + rulesetPlugin, err := launchPlugins(cli.config) + if rulesetPlugin != nil { + defer rulesetPlugin.Clean() } - if err := cfg.ValidateRules(rulesets...); err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to check rule config; %w", err), cli.loader.Sources()) - return ExitCodeError + if err != nil { + return tflint.Issues{}, err } // Run inspection @@ -128,29 +149,41 @@ func (cli *CLI) inspect(opts Options, dir string, filterFiles []string) int { for _, runner := range runners { err = ruleset.Check(plugin.NewGRPCServer(runner, rootRunner, cli.loader.Files())) if err != nil { - cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to check ruleset; %w", err), cli.loader.Sources()) - return ExitCodeError + return tflint.Issues{}, fmt.Errorf("Failed to check ruleset; %w", err) } } } - issues := tflint.Issues{} for _, runner := range runners { issues = append(issues, runner.LookupIssues(filterFiles...)...) } + // Set module sources to CLI + for path, source := range cli.loader.Sources() { + cli.sources[path] = source + } - // Print issues - cli.formatter.Print(issues, nil, cli.loader.Sources()) + return issues, nil +} - if len(issues) > 0 && !cfg.Force { - return ExitCodeIssuesFound +func (cli *CLI) withinChangedDir(dir string, proc func() error) (err error) { + if dir != "." { + chErr := os.Chdir(dir) + if chErr != nil { + return fmt.Errorf("Failed to switch to a different working directory; %w", chErr) + } + defer func() { + chErr := os.Chdir(cli.originalWorkingDir) + if chErr != nil { + err = fmt.Errorf("Failed to switch to the original working directory; %s; %w", chErr, err) + } + }() } - return ExitCodeOK + return proc() } -func (cli *CLI) setupRunners(opts Options, cfg *tflint.Config, originalWd string, dir string) ([]*tflint.Runner, error) { - configs, diags := cli.loader.LoadConfig(dir, cfg.Module) +func (cli *CLI) setupRunners(opts Options, dir string) ([]*tflint.Runner, error) { + configs, diags := cli.loader.LoadConfig(dir, cli.config.Module) if diags.HasErrors() { return []*tflint.Runner{}, fmt.Errorf("Failed to load configurations; %w", diags) } @@ -172,17 +205,17 @@ func (cli *CLI) setupRunners(opts Options, cfg *tflint.Config, originalWd string return []*tflint.Runner{}, fmt.Errorf("Failed to load configurations; %w", diags) } - variables, diags := cli.loader.LoadValuesFiles(dir, cfg.Varfiles...) + variables, diags := cli.loader.LoadValuesFiles(dir, cli.config.Varfiles...) if diags.HasErrors() { return []*tflint.Runner{}, fmt.Errorf("Failed to load values files; %w", diags) } - cliVars, diags := terraform.ParseVariableValues(cfg.Variables, configs.Module.Variables) + cliVars, diags := terraform.ParseVariableValues(cli.config.Variables, configs.Module.Variables) if diags.HasErrors() { return []*tflint.Runner{}, fmt.Errorf("Failed to parse variables; %w", diags) } variables = append(variables, cliVars) - runner, err := tflint.NewRunner(originalWd, cfg, annotations, configs, variables...) + runner, err := tflint.NewRunner(cli.originalWorkingDir, cli.config, annotations, configs, variables...) if err != nil { return []*tflint.Runner{}, fmt.Errorf("Failed to initialize a runner; %w", err) } @@ -194,3 +227,59 @@ func (cli *CLI) setupRunners(opts Options, cfg *tflint.Config, originalWd string return append(runners, runner), nil } + +func launchPlugins(config *tflint.Config) (*plugin.Plugin, error) { + // Lookup plugins + rulesetPlugin, err := plugin.Discovery(config) + if err != nil { + return nil, fmt.Errorf("Failed to initialize plugins; %w", err) + } + + rulesets := []tflint.RuleSet{} + pluginConf := config.ToPluginConfig() + + // Check version constraints and apply a config to plugins + for name, ruleset := range rulesetPlugin.RuleSets { + constraints, err := ruleset.VersionConstraints() + if err != nil { + if st, ok := status.FromError(err); ok && st.Code() == codes.Unimplemented { + // VersionConstraints endpoint is available in tflint-plugin-sdk v0.14+. + // Skip verification if not available. + } else { + return rulesetPlugin, fmt.Errorf("Failed to get TFLint version constraints to `%s` plugin; %w", name, err) + } + } + if !constraints.Check(tflint.Version) { + return rulesetPlugin, fmt.Errorf("Failed to satisfy version constraints; tflint-ruleset-%s requires %s, but TFLint version is %s", name, constraints, tflint.Version) + } + + if err := ruleset.ApplyGlobalConfig(pluginConf); err != nil { + return rulesetPlugin, fmt.Errorf("Failed to apply global config to `%s` plugin; %w", name, err) + } + configSchema, err := ruleset.ConfigSchema() + if err != nil { + return rulesetPlugin, fmt.Errorf("Failed to fetch config schema from `%s` plugin; %w", name, err) + } + content := &hclext.BodyContent{} + if plugin, exists := config.Plugins[name]; exists { + var diags hcl.Diagnostics + content, diags = plugin.Content(configSchema) + if diags.HasErrors() { + return rulesetPlugin, fmt.Errorf("Failed to parse `%s` plugin config; %w", name, diags) + } + } + err = ruleset.ApplyConfig(content, config.Sources()) + if err != nil { + return rulesetPlugin, fmt.Errorf("Failed to apply config to `%s` plugin; %w", name, err) + } + + rulesets = append(rulesets, ruleset) + } + + // Validate config for plugins + if err := config.ValidateRules(rulesets...); err != nil { + return rulesetPlugin, fmt.Errorf("Failed to check rule config; %w", err) + } + + return rulesetPlugin, nil +} diff --git a/cmd/langserver.go b/cmd/langserver.go index 37d909ec5..c59357d34 100644 --- a/cmd/langserver.go +++ b/cmd/langserver.go @@ -15,6 +15,11 @@ func (cli *CLI) startLanguageServer(opts Options) int { fmt.Fprintf(cli.errStream, "Cannot use --chdir with --langserver\n") return ExitCodeError } + if opts.Recursive { + fmt.Fprintf(cli.errStream, "Cannot use --recursive with --langserver\n") + return ExitCodeError + } + configPath := opts.Config cliConfig := opts.toConfig() diff --git a/cmd/option.go b/cmd/option.go index c8b1a3a6f..b28958269 100644 --- a/cmd/option.go +++ b/cmd/option.go @@ -23,6 +23,7 @@ type Options struct { Variables []string `long:"var" description:"Set a Terraform variable" value-name:"'foo=bar'"` Module bool `long:"module" description:"Inspect modules"` Chdir string `long:"chdir" description:"Switch to a different working directory before running inspection" value-name:"DIR"` + Recursive bool `long:"recursive" description:"Inspect directories recursively"` Force bool `long:"force" description:"Return zero exit status even if issues found"` Color bool `long:"color" description:"Enable colorized output"` NoColor bool `long:"no-color" description:"Disable colorized output"` diff --git a/cmd/version.go b/cmd/version.go index bc47b05dc..9dadfdbcc 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -14,6 +14,10 @@ func (cli *CLI) printVersion(opts Options) int { fmt.Fprintf(cli.errStream, "Cannot use --chdir with --version\n") return ExitCodeError } + if opts.Recursive { + fmt.Fprintf(cli.errStream, "Cannot use --recursive with --version\n") + return ExitCodeError + } fmt.Fprintf(cli.outStream, "TFLint version %s\n", tflint.Version) diff --git a/docs/user-guide/README.md b/docs/user-guide/README.md index 68b8b2e5a..507aa668b 100644 --- a/docs/user-guide/README.md +++ b/docs/user-guide/README.md @@ -7,6 +7,7 @@ This guide describes the various features of TFLint for end users. - [Introduction](../../README.md) (README) - [Configuring TFLint](config.md) - [Configuring Plugins](plugins.md) +- [Switching working directory](working-directory.md) - [Module Inspection](module-inspection.md) - [Annotations](annotations.md) - [Compatibility with Terraform](compatibility.md) diff --git a/docs/user-guide/config.md b/docs/user-guide/config.md index 6efa340d5..04f303fc8 100644 --- a/docs/user-guide/config.md +++ b/docs/user-guide/config.md @@ -5,6 +5,8 @@ You can change the behavior not only in CLI flags but also in config files. By d - Current directory (`./.tflint.hcl`) - Home directory (`~/.tflint.hcl`) +However, if `--chdir` or `--recursive` is used, The config file in the changed directory will be loaded. + The config file is written in [HCL](https://github.com/hashicorp/hcl). An example is shown below: ```hcl @@ -46,7 +48,16 @@ $ tflint --config other_config.hcl CLI flag: `--format` -Change the output format. +Change the output format. The following values are valid: + +- default +- json +- checkstyle +- junit +- compact +- sarif + +In recursive mode (`--recursive`), this field will be ignored in configuration files and must be set via a flag. ### `plugin_dir` @@ -68,6 +79,8 @@ Return zero exit status even if issues found. TFLint returns the following exit - 1: Errors occurred - 2: No errors occurred, but issues found +In recursive mode (`--recursive`), this field will be ignored in configuration files and must be set via a flag. + ### `disabled_by_default` CLI flag: `--only` diff --git a/docs/user-guide/working-directory.md b/docs/user-guide/working-directory.md new file mode 100644 index 000000000..944faf8c9 --- /dev/null +++ b/docs/user-guide/working-directory.md @@ -0,0 +1,27 @@ +# Switching working directory + +TFLint has `--chdir` and `--recursive` flags to inspect modules that are different from the current directory. + +The `--chdir` flag is available just like Terraform: + +```console +$ tflint --chdir=environments/production +``` + +Its behavior is the same as [Terraform's behavior](https://developer.hashicorp.com/terraform/cli/commands#switching-working-directory-with-chdir). You should be aware of the following points: + +- Config files are loaded after acting on the `--chdir` option. + - This means that `tflint --chdir=dir` will loads `dir/.tflint.hcl` instead of `./.tflint.hcl`. +- Relative paths are always resolved against the changed directory. + - If you want to refer to the file in the original working directory, it is recommended to pass the absolute path using realpath(1) etc. e.g. `tflint --config=$(realpath .tflint.hcl)`. +- The `path.cwd` represents the original working directory. This is the same behavior as using `--chdir` in Terraform. + +TFLint also accepts a directory as an argument, but `--chdir` is recommended in most cases. The directory argument is deprecated and may be removed in a future version. + +The `--recursive` flag enables recursive inspection. This is the same as running with `--chdir` for each directory. + +```console +$ tflint --recursive +``` + +It takes no arguments in recursive mode. Passing a directory or file name will result in an error. diff --git a/integrationtest/cli/cli_test.go b/integrationtest/cli/cli_test.go index aa98574c1..87ea8f8f0 100644 --- a/integrationtest/cli/cli_test.go +++ b/integrationtest/cli/cli_test.go @@ -288,6 +288,13 @@ func TestIntegration(t *testing.T) { status: cmd.ExitCodeError, stderr: "Cannot use --chdir and directory argument at the same time", }, + { + name: "--recursive and arguments", + command: "./tflint --recursive subdir", + dir: "multiple_files", + status: cmd.ExitCodeError, + stderr: "Cannot use --recursive and arguments at the same time", + }, } dir, _ := os.Getwd() @@ -308,7 +315,10 @@ func TestIntegration(t *testing.T) { } outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) - cli := cmd.NewCLI(outStream, errStream) + cli, err := cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } args := strings.Split(test.command, " ") got := cli.Run(args) diff --git a/integrationtest/init/init_test.go b/integrationtest/init/init_test.go index 56e33b14e..1762e64e9 100644 --- a/integrationtest/init/init_test.go +++ b/integrationtest/init/init_test.go @@ -35,7 +35,10 @@ func TestIntegration(t *testing.T) { defer os.Setenv("TFLINT_PLUGIN_DIR", "") outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) - cli := cmd.NewCLI(outStream, errStream) + cli, err := cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } cli.Run([]string{"./tflint"}) if !strings.Contains(errStream.String(), "Plugin `aws` not found. Did you run `tflint --init`?") { diff --git a/integrationtest/inspection/inspection_test.go b/integrationtest/inspection/inspection_test.go index 536d4076f..c36e3943f 100644 --- a/integrationtest/inspection/inspection_test.go +++ b/integrationtest/inspection/inspection_test.go @@ -208,6 +208,11 @@ func TestIntegration(t *testing.T) { Command: "tflint --chdir dir --module --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 @@ -242,7 +247,10 @@ func TestIntegration(t *testing.T) { } outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) - cli := cmd.NewCLI(outStream, errStream) + cli, err := cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } args := strings.Split(tc.Command, " ") cli.Run(args) diff --git a/integrationtest/inspection/recursive/result.json b/integrationtest/inspection/recursive/result.json new file mode 100644 index 000000000..1bc80855b --- /dev/null +++ b/integrationtest/inspection/recursive/result.json @@ -0,0 +1,45 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t2.micro", + "range": { + "filename": "subdir1/main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 29 + } + }, + "callers": [] + }, + { + "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/inspection/recursive/result_windows.json b/integrationtest/inspection/recursive/result_windows.json new file mode 100644 index 000000000..ca4f07bf4 --- /dev/null +++ b/integrationtest/inspection/recursive/result_windows.json @@ -0,0 +1,45 @@ +{ + "issues": [ + { + "rule": { + "name": "aws_instance_example_type", + "severity": "error", + "link": "" + }, + "message": "instance type is t2.micro", + "range": { + "filename": "subdir1\\main.tf", + "start": { + "line": 2, + "column": 19 + }, + "end": { + "line": 2, + "column": 29 + } + }, + "callers": [] + }, + { + "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/inspection/recursive/subdir1/.tflint.hcl b/integrationtest/inspection/recursive/subdir1/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/inspection/recursive/subdir1/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/inspection/recursive/subdir1/main.tf b/integrationtest/inspection/recursive/subdir1/main.tf new file mode 100644 index 000000000..43383052f --- /dev/null +++ b/integrationtest/inspection/recursive/subdir1/main.tf @@ -0,0 +1,3 @@ +resource "aws_instance" "foo" { + instance_type = "t2.micro" +} diff --git a/integrationtest/inspection/recursive/subdir2/.tflint.hcl b/integrationtest/inspection/recursive/subdir2/.tflint.hcl new file mode 100644 index 000000000..e19f589dd --- /dev/null +++ b/integrationtest/inspection/recursive/subdir2/.tflint.hcl @@ -0,0 +1,3 @@ +plugin "testing" { + enabled = true +} diff --git a/integrationtest/inspection/recursive/subdir2/main.tf b/integrationtest/inspection/recursive/subdir2/main.tf new file mode 100644 index 000000000..43383052f --- /dev/null +++ b/integrationtest/inspection/recursive/subdir2/main.tf @@ -0,0 +1,3 @@ +resource "aws_instance" "foo" { + instance_type = "t2.micro" +} diff --git a/main.go b/main.go index 3b72e1c2f..4a9bda0ae 100644 --- a/main.go +++ b/main.go @@ -12,7 +12,11 @@ import ( ) func main() { - cli := cmd.NewCLI(colorable.NewColorable(os.Stdout), colorable.NewColorable(os.Stderr)) + cli, err := cmd.NewCLI(colorable.NewColorable(os.Stdout), colorable.NewColorable(os.Stderr)) + if err != nil { + fmt.Fprint(os.Stderr, err.Error()) + os.Exit(cmd.ExitCodeError) + } defer func() { if r := recover(); r != nil { diff --git a/terraform/loader.go b/terraform/loader.go index bca1d06ae..b7dd85beb 100644 --- a/terraform/loader.go +++ b/terraform/loader.go @@ -180,6 +180,10 @@ func (l *Loader) LoadConfigDirFiles(dir string) (map[string]*hcl.File, hcl.Diagn return l.parser.LoadConfigDirFiles(l.baseDir, dir) } +func (l *Loader) IsConfigDir(path string) bool { + return l.parser.IsConfigDir(l.baseDir, path) +} + func (l *Loader) Sources() map[string][]byte { return l.parser.Sources() } diff --git a/terraform/parser.go b/terraform/parser.go index 97c66c664..f45a5d35c 100644 --- a/terraform/parser.go +++ b/terraform/parser.go @@ -229,6 +229,14 @@ func (p *Parser) Files() map[string]*hcl.File { return p.p.Files() } +// IsConfigDir determines whether the given path refers to a directory that +// exists and contains at least one Terraform config file (with a .tf or +// .tf.json extension.) +func (p *Parser) IsConfigDir(baseDir, path string) bool { + primaryPaths, overridePaths, _ := p.configDirFiles(baseDir, path) + return (len(primaryPaths) + len(overridePaths)) > 0 +} + func (p *Parser) configDirFiles(baseDir, dir string) (primary, override []string, diags hcl.Diagnostics) { infos, err := p.fs.ReadDir(dir) if err != nil { diff --git a/terraform/parser_test.go b/terraform/parser_test.go index 6849dfd98..3c03c9a56 100644 --- a/terraform/parser_test.go +++ b/terraform/parser_test.go @@ -414,3 +414,77 @@ func TestLoadValuesFile(t *testing.T) { }) } } + +func TestIsConfigDir(t *testing.T) { + tests := []struct { + name string + files map[string]string + baseDir string + dir string + want bool + }{ + { + name: "HCL native files (primary)", + files: map[string]string{ + "main.tf": "", + }, + baseDir: ".", + dir: ".", + want: true, + }, + { + name: "HCL native files (override)", + files: map[string]string{ + "override.tf": "", + }, + baseDir: ".", + dir: ".", + want: true, + }, + { + name: "HCL JSON files (primary)", + files: map[string]string{ + "main.tf.json": "{}", + }, + baseDir: ".", + dir: ".", + want: true, + }, + { + name: "HCL JSON files (override)", + files: map[string]string{ + "override.tf.json": "{}", + }, + baseDir: ".", + dir: ".", + want: true, + }, + { + name: "non-HCL files", + files: map[string]string{ + "README.md": "", + }, + baseDir: ".", + dir: ".", + want: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + fs := afero.Afero{Fs: afero.NewMemMapFs()} + for name, content := range test.files { + if err := fs.WriteFile(name, []byte(content), os.ModePerm); err != nil { + t.Fatal(err) + } + } + parser := NewParser(fs) + + got := parser.IsConfigDir(test.baseDir, test.dir) + + if got != test.want { + t.Errorf("want=%t, got=%t", test.want, got) + } + }) + } +}