Skip to content
116 changes: 105 additions & 11 deletions cmd/version.go
Original file line number Diff line number Diff line change
@@ -1,17 +1,75 @@
package cmd

import (
"context"
"encoding/json"
"fmt"
"log"
"maps"
"slices"
"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"`
UpdateCheckEnabled bool `json:"update_check_enabled"`
UpdateAvailable bool `json:"update_available"`
LatestVersion string `json:"latest_version,omitempty"`
}

// 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 +89,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 +104,62 @@ 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\nis %s. You 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 {
// Build output
output := VersionOutput{
Version: tflint.Version.String(),
Plugins: getPluginVersions(opts),
UpdateCheckEnabled: versioncheck.Enabled(),
}

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

// 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 +173,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
86 changes: 86 additions & 0 deletions versioncheck/cache.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package versioncheck

import (
"encoding/json"
"log"
"os"
"path/filepath"
"time"
)

const (
// CacheTTL is how long cached version info is considered valid
CacheTTL = 48 * time.Hour
)

// CacheEntry represents a cached version check result
type CacheEntry struct {
LatestVersion string `json:"latest_version"`
CheckedAt time.Time `json:"checked_at"`
}

// IsExpired returns whether the cache entry has exceeded its TTL
func (c *CacheEntry) IsExpired() bool {
return time.Since(c.CheckedAt) > CacheTTL
}

// loadCache reads and parses the cache file
// Returns nil if cache doesn't exist or is invalid
func loadCache() (*CacheEntry, error) {
cachePath, err := getCachePath()
if err != nil {
return nil, err
}

data, err := os.ReadFile(cachePath)
if err != nil {
if os.IsNotExist(err) {
log.Printf("[DEBUG] No cache file found at %s", cachePath)
return nil, nil
}
return nil, err
}

var entry CacheEntry
if err := json.Unmarshal(data, &entry); err != nil {
log.Printf("[DEBUG] Failed to parse cache file: %s", err)
return nil, err
}

return &entry, nil
}

// saveCache writes the cache entry to disk
func saveCache(entry *CacheEntry) error {
cachePath, err := getCachePath()
if err != nil {
return err
}

// Ensure directory exists
cacheDir := filepath.Dir(cachePath)
if err := os.MkdirAll(cacheDir, 0755); err != nil {
return err
}

data, err := json.MarshalIndent(entry, "", " ")
if err != nil {
return err
}

if err := os.WriteFile(cachePath, data, 0644); err != nil {
return err
}

log.Printf("[DEBUG] Saved version check cache to %s", cachePath)
return nil
}

// getCachePath returns the full path to the cache file using platform-specific cache directory
func getCachePath() (string, error) {
cacheDir, err := os.UserCacheDir()
if err != nil {
return "", err
}
return filepath.Join(cacheDir, "tflint", "version_check_cache.json"), nil
}
54 changes: 54 additions & 0 deletions versioncheck/cache_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package versioncheck

import (
"testing"
"time"
)

func TestCacheEntry_IsExpired(t *testing.T) {
tests := []struct {
name string
checkedAt time.Time
want bool
}{
{
name: "fresh cache (1 hour old)",
checkedAt: time.Now().Add(-1 * time.Hour),
want: false,
},
{
name: "fresh cache (24 hours old)",
checkedAt: time.Now().Add(-24 * time.Hour),
want: false,
},
{
name: "expired cache (49 hours old)",
checkedAt: time.Now().Add(-49 * time.Hour),
want: true,
},
{
name: "just expired (48 hours + 1 minute)",
checkedAt: time.Now().Add(-48*time.Hour - 1*time.Minute),
want: true,
},
{
name: "just fresh (47 hours)",
checkedAt: time.Now().Add(-47 * time.Hour),
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
entry := &CacheEntry{
LatestVersion: "0.60.0",
CheckedAt: tt.checkedAt,
}

got := entry.IsExpired()
if got != tt.want {
t.Errorf("CacheEntry.IsExpired() = %v, want %v", got, tt.want)
}
})
}
}
Loading
Loading