Skip to content

Commit 18a0ec4

Browse files
authored
version: add update notifications, json output (#2421)
Implements version update notifications similar to Terraform's pattern. When running tflint --version, users see a notification if a newer version is available on GitHub Releases.
1 parent b41fd2e commit 18a0ec4

File tree

10 files changed

+979
-11
lines changed

10 files changed

+979
-11
lines changed

cmd/version.go

Lines changed: 170 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,84 @@
11
package cmd
22

33
import (
4+
"cmp"
5+
"context"
6+
"encoding/json"
47
"fmt"
58
"log"
69
"maps"
710
"slices"
11+
"strings"
12+
"time"
813

914
"github.com/spf13/afero"
1015
"github.com/terraform-linters/tflint/plugin"
1116
"github.com/terraform-linters/tflint/tflint"
17+
"github.com/terraform-linters/tflint/versioncheck"
1218
)
1319

20+
const (
21+
versionCheckTimeout = 3 * time.Second
22+
)
23+
24+
// VersionOutput is the JSON output structure for version command
25+
type VersionOutput struct {
26+
Version string `json:"version"`
27+
Plugins []PluginVersion `json:"plugins,omitempty"`
28+
Modules []ModuleVersionOutput `json:"modules,omitempty"`
29+
UpdateCheckEnabled bool `json:"update_check_enabled"`
30+
UpdateAvailable bool `json:"update_available"`
31+
LatestVersion string `json:"latest_version,omitempty"`
32+
}
33+
34+
// ModuleVersionOutput represents plugins for a specific module
35+
type ModuleVersionOutput struct {
36+
Path string `json:"path"`
37+
Plugins []PluginVersion `json:"plugins"`
38+
}
39+
40+
// PluginVersion represents a plugin's name and version
41+
type PluginVersion struct {
42+
Name string `json:"name"`
43+
Version string `json:"version"`
44+
}
45+
1446
func (cli *CLI) printVersion(opts Options) int {
47+
// For JSON format: perform synchronous version check
48+
if opts.Format == "json" {
49+
var updateInfo *versioncheck.UpdateInfo
50+
if versioncheck.Enabled() {
51+
ctx, cancel := context.WithTimeout(context.Background(), versionCheckTimeout)
52+
defer cancel()
53+
54+
info, err := versioncheck.CheckForUpdate(ctx, tflint.Version)
55+
if err != nil {
56+
log.Printf("[ERROR] Failed to check for updates: %s", err)
57+
} else {
58+
updateInfo = info
59+
}
60+
}
61+
return cli.printVersionJSON(opts, updateInfo)
62+
}
63+
64+
// For text format: start async version check
65+
var updateChan chan *versioncheck.UpdateInfo
66+
if versioncheck.Enabled() {
67+
updateChan = make(chan *versioncheck.UpdateInfo, 1)
68+
go func() {
69+
ctx, cancel := context.WithTimeout(context.Background(), versionCheckTimeout)
70+
defer cancel()
71+
72+
info, err := versioncheck.CheckForUpdate(ctx, tflint.Version)
73+
if err != nil {
74+
log.Printf("[ERROR] Failed to check for updates: %s", err)
75+
}
76+
updateChan <- info
77+
close(updateChan)
78+
}()
79+
}
80+
81+
// Print version immediately
1582
fmt.Fprintf(cli.outStream, "TFLint version %s\n", tflint.Version)
1683

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

34-
versions := getPluginVersions(opts)
101+
plugins := getPluginVersions(opts)
35102

36-
for _, version := range versions {
37-
fmt.Fprint(cli.outStream, version)
103+
for _, plugin := range plugins {
104+
fmt.Fprintf(cli.outStream, "+ %s (%s)\n", plugin.Name, plugin.Version)
38105
}
39-
if len(versions) == 0 && opts.Recursive {
106+
if len(plugins) == 0 && opts.Recursive {
40107
fmt.Fprint(cli.outStream, "No plugins\n")
41108
}
42109
return nil
@@ -46,29 +113,118 @@ func (cli *CLI) printVersion(opts Options) int {
46113
}
47114
}
48115

116+
// Wait for update check to complete and print notification if available
117+
if updateChan != nil {
118+
updateInfo := <-updateChan
119+
if updateInfo != nil && updateInfo.Available {
120+
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)
121+
}
122+
}
123+
49124
return ExitCodeOK
50125
}
51126

52-
func getPluginVersions(opts Options) []string {
53-
// Load configuration files to print plugin versions
127+
func (cli *CLI) printVersionJSON(opts Options, updateInfo *versioncheck.UpdateInfo) int {
128+
workingDirs, err := findWorkingDirs(opts)
129+
if err != nil {
130+
cli.formatter.Print(tflint.Issues{}, fmt.Errorf("Failed to find workspaces; %w", err), map[string][]byte{})
131+
return ExitCodeError
132+
}
133+
134+
// Build output
135+
output := VersionOutput{
136+
Version: tflint.Version.String(),
137+
UpdateCheckEnabled: versioncheck.Enabled(),
138+
}
139+
140+
if updateInfo != nil {
141+
output.UpdateAvailable = updateInfo.Available
142+
if updateInfo.Available {
143+
output.LatestVersion = updateInfo.Latest
144+
}
145+
}
146+
147+
// Handle multiple working directories for --recursive
148+
if opts.Recursive && len(workingDirs) > 1 {
149+
// Track all unique plugins across modules
150+
pluginMap := make(map[string]PluginVersion)
151+
152+
for _, wd := range workingDirs {
153+
var plugins []PluginVersion
154+
err := cli.withinChangedDir(wd, func() error {
155+
plugins = getPluginVersions(opts)
156+
return nil
157+
})
158+
if err != nil {
159+
log.Printf("[ERROR] Failed to get plugins for %s: %s", wd, err)
160+
continue
161+
}
162+
163+
// Add to modules output
164+
output.Modules = append(output.Modules, ModuleVersionOutput{
165+
Path: wd,
166+
Plugins: plugins,
167+
})
168+
169+
// Accumulate unique plugins
170+
for _, plugin := range plugins {
171+
key := plugin.Name + "@" + plugin.Version
172+
pluginMap[key] = plugin
173+
}
174+
}
175+
176+
// Convert map to sorted slice for consistent output
177+
for _, plugin := range pluginMap {
178+
output.Plugins = append(output.Plugins, plugin)
179+
}
180+
slices.SortFunc(output.Plugins, func(a, b PluginVersion) int {
181+
return cmp.Or(
182+
strings.Compare(a.Name, b.Name),
183+
strings.Compare(a.Version, b.Version),
184+
)
185+
})
186+
} else {
187+
// Single directory mode (backwards compatible)
188+
err := cli.withinChangedDir(workingDirs[0], func() error {
189+
output.Plugins = getPluginVersions(opts)
190+
return nil
191+
})
192+
if err != nil {
193+
cli.formatter.Print(tflint.Issues{}, err, map[string][]byte{})
194+
return ExitCodeError
195+
}
196+
}
197+
198+
// Marshal and print JSON
199+
jsonBytes, err := json.MarshalIndent(output, "", " ")
200+
if err != nil {
201+
log.Printf("[ERROR] Failed to marshal JSON: %s", err)
202+
return ExitCodeError
203+
}
204+
205+
fmt.Fprintln(cli.outStream, string(jsonBytes))
206+
return ExitCodeOK
207+
}
208+
209+
func getPluginVersions(opts Options) []PluginVersion {
54210
cfg, err := tflint.LoadConfig(afero.Afero{Fs: afero.NewOsFs()}, opts.Config)
55211
if err != nil {
56212
log.Printf("[ERROR] Failed to load TFLint config: %s", err)
57-
return []string{}
213+
return []PluginVersion{}
58214
}
59215
cfg.Merge(opts.toConfig())
60216

61217
rulesetPlugin, err := plugin.Discovery(cfg)
62218
if err != nil {
63219
log.Printf("[ERROR] Failed to initialize plugins: %s", err)
64-
return []string{}
220+
return []PluginVersion{}
65221
}
66222
defer rulesetPlugin.Clean()
67223

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

71-
versions := []string{}
227+
plugins := []PluginVersion{}
72228
for _, name := range rulesetNames {
73229
ruleset := rulesetPlugin.RuleSets[name]
74230
rulesetName, err := ruleset.RuleSetName()
@@ -82,8 +238,11 @@ func getPluginVersions(opts Options) []string {
82238
continue
83239
}
84240

85-
versions = append(versions, fmt.Sprintf("+ ruleset.%s (%s)\n", rulesetName, version))
241+
plugins = append(plugins, PluginVersion{
242+
Name: fmt.Sprintf("ruleset.%s", rulesetName),
243+
Version: version,
244+
})
86245
}
87246

88-
return versions
247+
return plugins
89248
}

docs/user-guide/environment_variables.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@ Below is a list of environment variables available in TFLint.
88
- Configure the config file path. See [Configuring TFLint](./config.md).
99
- `TFLINT_PLUGIN_DIR`
1010
- Configure the plugin directory. See [Configuring Plugins](./plugins.md).
11+
- `TFLINT_DISABLE_VERSION_CHECK`
12+
- Disable version update notifications when running `tflint --version`. Set to `1` to disable.
13+
- `GITHUB_TOKEN`
14+
- (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.
1115
- `TFLINT_EXPERIMENTAL`
1216
- Enable experimental features. Note that experimental features are subject to change without notice. Currently only [Keyless Verification](./plugins.md#keyless-verification-experimental) are supported.
1317
- `TF_VAR_name`

0 commit comments

Comments
 (0)