Skip to content
Open
181 changes: 170 additions & 11 deletions cmd/version.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,84 @@
package cmd

import (
"cmp"
"context"
"encoding/json"
"fmt"
"log"
"maps"
"slices"
"strings"
"time"

"github.com/spf13/afero"
"github.com/terraform-linters/tflint/plugin"
"github.com/terraform-linters/tflint/tflint"
"github.com/terraform-linters/tflint/versioncheck"
)

const (
versionCheckTimeout = 3 * time.Second
)

// VersionOutput is the JSON output structure for version command
type VersionOutput struct {
Version string `json:"version"`
Plugins []PluginVersion `json:"plugins,omitempty"`
Modules []ModuleVersionOutput `json:"modules,omitempty"`
UpdateCheckEnabled bool `json:"update_check_enabled"`
UpdateAvailable bool `json:"update_available"`
LatestVersion string `json:"latest_version,omitempty"`
}

// ModuleVersionOutput represents plugins for a specific module
type ModuleVersionOutput struct {
Path string `json:"path"`
Plugins []PluginVersion `json:"plugins"`
}

// PluginVersion represents a plugin's name and version
type PluginVersion struct {
Name string `json:"name"`
Version string `json:"version"`
}

func (cli *CLI) printVersion(opts Options) int {
// For JSON format: perform synchronous version check
if opts.Format == "json" {
var updateInfo *versioncheck.UpdateInfo
if versioncheck.Enabled() {
ctx, cancel := context.WithTimeout(context.Background(), versionCheckTimeout)
defer cancel()

info, err := versioncheck.CheckForUpdate(ctx, tflint.Version)
if err != nil {
log.Printf("[ERROR] Failed to check for updates: %s", err)
} else {
updateInfo = info
}
}
return cli.printVersionJSON(opts, updateInfo)
}

// For text format: start async version check
var updateChan chan *versioncheck.UpdateInfo
if versioncheck.Enabled() {
updateChan = make(chan *versioncheck.UpdateInfo, 1)
go func() {
ctx, cancel := context.WithTimeout(context.Background(), versionCheckTimeout)
defer cancel()

info, err := versioncheck.CheckForUpdate(ctx, tflint.Version)
if err != nil {
log.Printf("[ERROR] Failed to check for updates: %s", err)
}
updateChan <- info
close(updateChan)
}()
}

// Print version immediately
fmt.Fprintf(cli.outStream, "TFLint version %s\n", tflint.Version)

workingDirs, err := findWorkingDirs(opts)
Expand All @@ -31,12 +98,12 @@ func (cli *CLI) printVersion(opts Options) int {
fmt.Fprintf(cli.outStream, "working directory: %s\n\n", wd)
}

versions := getPluginVersions(opts)
plugins := getPluginVersions(opts)

for _, version := range versions {
fmt.Fprint(cli.outStream, version)
for _, plugin := range plugins {
fmt.Fprintf(cli.outStream, "+ %s (%s)\n", plugin.Name, plugin.Version)
}
if len(versions) == 0 && opts.Recursive {
if len(plugins) == 0 && opts.Recursive {
fmt.Fprint(cli.outStream, "No plugins\n")
}
return nil
Expand All @@ -46,29 +113,118 @@ func (cli *CLI) printVersion(opts Options) int {
}
}

// Wait for update check to complete and print notification if available
if updateChan != nil {
updateInfo := <-updateChan
if updateInfo != nil && updateInfo.Available {
fmt.Fprintf(cli.outStream, "\nYour version of TFLint is out of date! The latest version is %s.\nYou can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)
}
}

return ExitCodeOK
}

func getPluginVersions(opts Options) []string {
// Load configuration files to print plugin versions
func (cli *CLI) printVersionJSON(opts Options, updateInfo *versioncheck.UpdateInfo) 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
}

// Build output
output := VersionOutput{
Version: tflint.Version.String(),
UpdateCheckEnabled: versioncheck.Enabled(),
}

if updateInfo != nil {
output.UpdateAvailable = updateInfo.Available
if updateInfo.Available {
output.LatestVersion = updateInfo.Latest
}
}

// Handle multiple working directories for --recursive
if opts.Recursive && len(workingDirs) > 1 {
// Track all unique plugins across modules
pluginMap := make(map[string]PluginVersion)

for _, wd := range workingDirs {
var plugins []PluginVersion
err := cli.withinChangedDir(wd, func() error {
plugins = getPluginVersions(opts)
return nil
})
if err != nil {
log.Printf("[ERROR] Failed to get plugins for %s: %s", wd, err)
continue
}

// Add to modules output
output.Modules = append(output.Modules, ModuleVersionOutput{
Path: wd,
Plugins: plugins,
})

// Accumulate unique plugins
for _, plugin := range plugins {
key := plugin.Name + "@" + plugin.Version
pluginMap[key] = plugin
}
}

// Convert map to sorted slice for consistent output
for _, plugin := range pluginMap {
output.Plugins = append(output.Plugins, plugin)
}
slices.SortFunc(output.Plugins, func(a, b PluginVersion) int {
return cmp.Or(
strings.Compare(a.Name, b.Name),
strings.Compare(a.Version, b.Version),
)
})
} else {
// Single directory mode (backwards compatible)
err := cli.withinChangedDir(workingDirs[0], func() error {
output.Plugins = getPluginVersions(opts)
return nil
})
if err != nil {
cli.formatter.Print(tflint.Issues{}, err, map[string][]byte{})
return ExitCodeError
}
}

// Marshal and print JSON
jsonBytes, err := json.MarshalIndent(output, "", " ")
if err != nil {
log.Printf("[ERROR] Failed to marshal JSON: %s", err)
return ExitCodeError
}

fmt.Fprintln(cli.outStream, string(jsonBytes))
return ExitCodeOK
}

func getPluginVersions(opts Options) []PluginVersion {
cfg, err := tflint.LoadConfig(afero.Afero{Fs: afero.NewOsFs()}, opts.Config)
if err != nil {
log.Printf("[ERROR] Failed to load TFLint config: %s", err)
return []string{}
return []PluginVersion{}
}
cfg.Merge(opts.toConfig())

rulesetPlugin, err := plugin.Discovery(cfg)
if err != nil {
log.Printf("[ERROR] Failed to initialize plugins: %s", err)
return []string{}
return []PluginVersion{}
}
defer rulesetPlugin.Clean()

// Sort ruleset names to ensure consistent ordering
rulesetNames := slices.Sorted(maps.Keys(rulesetPlugin.RuleSets))

versions := []string{}
plugins := []PluginVersion{}
for _, name := range rulesetNames {
ruleset := rulesetPlugin.RuleSets[name]
rulesetName, err := ruleset.RuleSetName()
Expand All @@ -82,8 +238,11 @@ func getPluginVersions(opts Options) []string {
continue
}

versions = append(versions, fmt.Sprintf("+ ruleset.%s (%s)\n", rulesetName, version))
plugins = append(plugins, PluginVersion{
Name: fmt.Sprintf("ruleset.%s", rulesetName),
Version: version,
})
}

return versions
return plugins
}
4 changes: 4 additions & 0 deletions docs/user-guide/environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ Below is a list of environment variables available in TFLint.
- Configure the config file path. See [Configuring TFLint](./config.md).
- `TFLINT_PLUGIN_DIR`
- Configure the plugin directory. See [Configuring Plugins](./plugins.md).
- `TFLINT_DISABLE_VERSION_CHECK`
- Disable version update notifications when running `tflint --version`. Set to `1` to disable.
- `GITHUB_TOKEN`
- (Optional) Used for authenticated GitHub API requests when checking for updates and downloading plugins. Increases the rate limit from 60 to 5000 requests per hour. Useful if you encounter rate limit errors. You can obtain a token by creating a [GitHub personal access token](https://github.com/settings/tokens); no special scopes are required.
- `TFLINT_EXPERIMENTAL`
- Enable experimental features. Note that experimental features are subject to change without notice. Currently only [Keyless Verification](./plugins.md#keyless-verification-experimental) are supported.
- `TF_VAR_name`
Expand Down
Loading
Loading