Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 87 additions & 11 deletions cmd/version.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,68 @@
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"`
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 {
// Check for updates (unless disabled)
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
}
}

// If JSON format requested, output JSON
if opts.Format == "json" {
return cli.printVersionJSON(opts, updateInfo)
}

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

// Print update notification if available
if updateInfo != nil && updateInfo.Available {
fmt.Fprintf(cli.outStream, "\n")
fmt.Fprintf(cli.outStream, "Your version of TFLint is out of date! The latest version\n")
fmt.Fprintf(cli.outStream, "is %s. You can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The update notification message uses fmt.Fprintf with multiple separate calls, which could lead to interleaved output in concurrent scenarios. Consider consolidating into a single fmt.Fprintf call or using a formatted string. For example:

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)
Suggested change
fmt.Fprintf(cli.outStream, "\n")
fmt.Fprintf(cli.outStream, "Your version of TFLint is out of date! The latest version\n")
fmt.Fprintf(cli.outStream, "is %s. You can update by downloading from https://github.com/terraform-linters/tflint/releases\n", updateInfo.Latest)
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)

Copilot uses AI. Check for mistakes.
}
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The version check is performed synchronously before printing the version (lines 37-49), which delays the version output by up to 3 seconds even when the cache is valid. Consider performing the version check and output in parallel: print the version immediately, then show the update notification after if needed. This would improve perceived responsiveness for users.

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Consider adding integration tests for the new version check functionality and JSON output format. The existing integration tests in integrationtest/cli/cli_test.go test basic version output, but don't cover the new features like update notifications or --format json with the version command. This would help ensure the feature works end-to-end in real-world scenarios.

Copilot uses AI. Check for mistakes.

workingDirs, err := findWorkingDirs(opts)
if err != nil {
cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to find workspaces; %w", err), map[string][]byte{})
Expand All @@ -31,12 +80,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 @@ -49,26 +98,50 @@ func (cli *CLI) printVersion(opts Options) int {
return ExitCodeOK
}

func getPluginVersions(opts Options) []string {
// Load configuration files to print plugin versions
func (cli *CLI) printVersionJSON(opts Options, updateInfo *versioncheck.UpdateInfo) int {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The printVersionJSON does not seem to take --chdir or --recursive into account.

// Build output
output := VersionOutput{
Version: tflint.Version.String(),
Plugins: getPluginVersions(opts),
}

if updateInfo != nil {
output.UpdateAvailable = updateInfo.Available
if updateInfo.Available {
output.LatestVersion = updateInfo.Latest
}
}
Comment on lines 118 to 131
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The JSON output structure may be ambiguous when version checking fails or is disabled. The update_available field will be false in three different scenarios:

  1. Version check succeeded and no update is available
  2. Version check was disabled via TFLINT_DISABLE_VERSION_CHECK
  3. Version check failed (network error, API error, etc.)

Consider adding an additional field to distinguish these cases, such as:

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"`
}

This would make the JSON output clearer for programmatic consumers.

Copilot uses AI. Check for mistakes.

// 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 +155,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
}
2 changes: 2 additions & 0 deletions docs/user-guide/environment_variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ 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.
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The GITHUB_TOKEN environment variable is used for authenticated GitHub API requests (as mentioned in lines 23-27 of versioncheck/github.go) but is not documented in the environment variables documentation. Consider adding documentation for this optional variable explaining:

  • Its purpose (to increase GitHub API rate limits from 60 to 5000 req/hour)
  • When it's needed
  • How to obtain a token
Suggested change
- Disable version update notifications when running `tflint --version`. Set to `1` to disable.
- Disable version update notifications when running `tflint --version`. Set to `1` to disable.
- `GITHUB_TOKEN`
- (Optional) Used for authenticated GitHub API requests to increase the rate limit from 60 to 5000 requests per hour. This is needed if you encounter rate limit errors when running commands that check for updates or interact with GitHub. You can obtain a token by creating a [GitHub personal access token](https://github.com/settings/tokens); no special scopes are required.

Copilot uses AI. Check for mistakes.
- `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
}

Comment on lines +71 to +74
Copy link

Copilot AI Nov 27, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] The cache file operations in loadCache and saveCache are not protected against concurrent access. If multiple tflint processes run --version simultaneously, they could race on reading/writing the cache file. Consider using file locking (e.g., syscall.Flock on Unix or similar) or atomic file operations (write to temp file, then rename) to prevent potential corruption. However, since cache corruption would only result in an extra API call (which is not critical), this may be acceptable as-is.

Suggested change
if err := os.WriteFile(cachePath, data, 0644); err != nil {
return err
}
// Write to a temp file, then atomically rename
tmpFile, err := os.CreateTemp(cacheDir, "version_check_cache_*.tmp")
if err != nil {
return err
}
defer func() {
tmpFile.Close()
os.Remove(tmpFile.Name()) // Clean up temp file if rename fails
}()
if _, err := tmpFile.Write(data); err != nil {
return err
}
if err := tmpFile.Sync(); err != nil {
return err
}
if err := tmpFile.Close(); err != nil {
return err
}
if err := os.Rename(tmpFile.Name(), cachePath); err != nil {
return err
}

Copilot uses AI. Check for mistakes.
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)
}
})
}
}
84 changes: 84 additions & 0 deletions versioncheck/check.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
package versioncheck

import (
"context"
"log"
"os"
"strconv"
"strings"
"time"

"github.com/hashicorp/go-version"
)

// UpdateInfo contains information about available updates
type UpdateInfo struct {
Available bool
Latest string
}

// Enabled returns whether version checking is enabled
func Enabled() bool {
val := os.Getenv("TFLINT_DISABLE_VERSION_CHECK")
if val == "" {
return true
}

disabled, err := strconv.ParseBool(val)
if err != nil {
return true
}

return !disabled
}

// CheckForUpdate checks if a new version of tflint is available
// It returns UpdateInfo indicating if an update is available and the latest version string
// Errors are logged but not returned - failures should not break the version command
func CheckForUpdate(ctx context.Context, current *version.Version) (*UpdateInfo, error) {
Comment on lines 35 to 37
Copy link

Copilot AI Nov 26, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function comment on line 37 states "Errors are logged but not returned - failures should not break the version command", but the function does return errors (line 53). This inconsistency between documentation and implementation is misleading. Either update the comment to reflect that errors are returned, or change the implementation to match the documented behavior of only logging errors.

Copilot uses AI. Check for mistakes.

// Try to load from cache first
cache, err := loadCache()
if err != nil {
log.Printf("[DEBUG] Failed to load version check cache: %s", err)
} else if cache != nil && !cache.IsExpired() {
log.Printf("[DEBUG] Using cached version check result")
return compareVersions(current, cache.LatestVersion)
}

// Cache miss or expired, fetch from GitHub
log.Printf("[DEBUG] Checking for TFLint updates...")
latestVersion, err := fetchLatestRelease(ctx)
if err != nil {
return nil, err
}

// Save to cache (non-blocking, errors logged only)
if err := saveCache(&CacheEntry{
LatestVersion: latestVersion,
CheckedAt: time.Now(),
}); err != nil {
log.Printf("[DEBUG] Failed to save version check cache: %s", err)
}

return compareVersions(current, latestVersion)
}

// compareVersions compares current and latest versions and returns UpdateInfo
func compareVersions(current *version.Version, latestStr string) (*UpdateInfo, error) {
// Strip leading "v" if present
latestStr = strings.TrimPrefix(latestStr, "v")

latest, err := version.NewVersion(latestStr)
if err != nil {
log.Printf("[DEBUG] Failed to parse latest version %q: %s", latestStr, err)
return nil, err
}

log.Printf("[DEBUG] Current version: %s, Latest version: %s", current, latest)

return &UpdateInfo{
Available: latest.GreaterThan(current),
Latest: latestStr,
}, nil
}
Loading
Loading