-
Notifications
You must be signed in to change notification settings - Fork 382
version: add update notifications, json output
#2421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from 6 commits
b1bed7e
e43c2fa
9fb1bfc
ce2d68c
0acd69f
3bf548c
740d22c
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
|
||
|
|
||
| workingDirs, err := findWorkingDirs(opts) | ||
| if err != nil { | ||
| cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to find workspaces; %w", err), map[string][]byte{}) | ||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The |
||
| // 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
|
||
|
|
||
| // 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() | ||
|
|
@@ -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 | ||
| } | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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. | ||||||||||
|
||||||||||
| - 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
AI
Nov 27, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
[nitpick] The GITHUB_TOKEN documentation mentions it's used "when checking for updates and downloading plugins" but this placement under version-related variables might be confusing. Consider moving this documentation to a more general location since it already exists elsewhere in the docs and is used for multiple purposes, not just version checking. Also note that the existing plugin code already documents GITHUB_TOKEN usage in the install.go comments (lines 336-344).
| 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
|
||||||||||||||||||||||||||||||||||||||||||||||||||||
| 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 | |
| } |
| 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) | ||
| } | ||
| }) | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| 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 | ||
| func CheckForUpdate(ctx context.Context, current *version.Version) (*UpdateInfo, error) { | ||
|
|
||
| // 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 | ||
| } |
There was a problem hiding this comment.
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.Fprintfwith multiple separate calls, which could lead to interleaved output in concurrent scenarios. Consider consolidating into a singlefmt.Fprintfcall or using a formatted string. For example: