diff --git a/cmd/version.go b/cmd/version.go index d75b31ec9..ac1a008eb 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -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) @@ -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 @@ -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() @@ -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 } diff --git a/docs/user-guide/environment_variables.md b/docs/user-guide/environment_variables.md index b6df58505..30d3ff6e5 100644 --- a/docs/user-guide/environment_variables.md +++ b/docs/user-guide/environment_variables.md @@ -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` diff --git a/integrationtest/version/version_test.go b/integrationtest/version/version_test.go new file mode 100644 index 000000000..83c94504c --- /dev/null +++ b/integrationtest/version/version_test.go @@ -0,0 +1,252 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/google/go-cmp/cmp/cmpopts" + "github.com/terraform-linters/tflint/cmd" + "github.com/terraform-linters/tflint/tflint" +) + +func TestVersionRecursiveWithPlugins(t *testing.T) { + // Disable the bundled plugin because os.Executable() returns go(1) in tests + tflint.DisableBundledPlugin = true + t.Cleanup(func() { + tflint.DisableBundledPlugin = false + }) + + // Create test directory structure + tmpDir := t.TempDir() + pluginDir := filepath.Join(tmpDir, ".tflint.d") + t.Setenv("TFLINT_PLUGIN_DIR", pluginDir) + + module1 := filepath.Join(tmpDir, "module1") + module2 := filepath.Join(tmpDir, "module2") + + if err := os.MkdirAll(module1, 0755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(module2, 0755); err != nil { + t.Fatal(err) + } + + // Root config: aws plugin + rootConfig := ` +plugin "aws" { + enabled = true + version = "0.21.1" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} +` + if err := os.WriteFile(filepath.Join(tmpDir, ".tflint.hcl"), []byte(rootConfig), 0644); err != nil { + t.Fatal(err) + } + + // Module 1: aws plugin (duplicate) + module1Config := ` +plugin "aws" { + enabled = true + version = "0.21.1" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} +` + if err := os.WriteFile(filepath.Join(module1, ".tflint.hcl"), []byte(module1Config), 0644); err != nil { + t.Fatal(err) + } + + // Module 2: google plugin (different) + module2Config := ` +plugin "google" { + enabled = true + version = "0.21.0" + source = "github.com/terraform-linters/tflint-ruleset-google" +} +` + if err := os.WriteFile(filepath.Join(module2, ".tflint.hcl"), []byte(module2Config), 0644); err != nil { + t.Fatal(err) + } + + t.Chdir(tmpDir) + + // First, run init to install plugins + outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) + cli, err := cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } + + exitCode := cli.Run([]string{"tflint", "--recursive", "--init"}) + if exitCode != cmd.ExitCodeOK { + t.Fatalf("init failed with exit code %d\nstdout: %s\nstderr: %s", exitCode, outStream.String(), errStream.String()) + } + + // Now run version command + outStream.Reset() + errStream.Reset() + cli, err = cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } + + t.Setenv("TFLINT_DISABLE_VERSION_CHECK", "1") + exitCode = cli.Run([]string{"tflint", "--recursive", "--version", "--format=json"}) + if exitCode != cmd.ExitCodeOK { + t.Fatalf("version failed with exit code %d\nstdout: %s\nstderr: %s", exitCode, outStream.String(), errStream.String()) + } + + var output cmd.VersionOutput + if err := json.Unmarshal(outStream.Bytes(), &output); err != nil { + t.Fatalf("failed to unmarshal JSON: %s\noutput: %s", err, outStream.String()) + } + + // Verify modules are present (3 directories: ., module1, module2) + if len(output.Modules) != 3 { + t.Errorf("expected 3 modules, got %d: %+v", len(output.Modules), output.Modules) + } + + // Verify module paths + var gotPaths []string + for _, mod := range output.Modules { + gotPaths = append(gotPaths, mod.Path) + } + + expectedPaths := []string{".", "module1", "module2"} + opts := []cmp.Option{ + cmpopts.SortSlices(func(a, b string) bool { return a < b }), + } + + if diff := cmp.Diff(expectedPaths, gotPaths, opts...); diff != "" { + t.Errorf("module paths mismatch (-want +got):\n%s", diff) + } + + // Verify deduplicated plugins list contains both aws and google + if len(output.Plugins) != 2 { + t.Errorf("expected 2 deduplicated plugins (aws, google), got %d: %+v", len(output.Plugins), output.Plugins) + } + + foundAWS := false + foundGoogle := false + for _, p := range output.Plugins { + if p.Name == "ruleset.aws" && p.Version == "0.21.1" { + foundAWS = true + } + if p.Name == "ruleset.google" && p.Version == "0.21.0" { + foundGoogle = true + } + } + + if !foundAWS { + t.Errorf("expected aws plugin in deduplicated list, got: %+v", output.Plugins) + } + if !foundGoogle { + t.Errorf("expected google plugin in deduplicated list, got: %+v", output.Plugins) + } + + // Verify plugins are sorted by name + if len(output.Plugins) >= 2 { + if output.Plugins[0].Name > output.Plugins[1].Name { + t.Errorf("plugins should be sorted by name, got: %+v", output.Plugins) + } + } +} + +func TestVersionNonRecursive(t *testing.T) { + tflint.DisableBundledPlugin = true + t.Cleanup(func() { + tflint.DisableBundledPlugin = false + }) + + tmpDir := t.TempDir() + pluginDir := filepath.Join(tmpDir, ".tflint.d") + t.Setenv("TFLINT_PLUGIN_DIR", pluginDir) + + config := ` +plugin "aws" { + enabled = true + version = "0.21.1" + source = "github.com/terraform-linters/tflint-ruleset-aws" +} +` + if err := os.WriteFile(filepath.Join(tmpDir, ".tflint.hcl"), []byte(config), 0644); err != nil { + t.Fatal(err) + } + + t.Chdir(tmpDir) + + // Init + outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) + cli, err := cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } + + exitCode := cli.Run([]string{"tflint", "--init"}) + if exitCode != cmd.ExitCodeOK { + t.Fatalf("init failed with exit code %d\nstdout: %s\nstderr: %s", exitCode, outStream.String(), errStream.String()) + } + + // Version (non-recursive) + outStream.Reset() + errStream.Reset() + cli, err = cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } + + t.Setenv("TFLINT_DISABLE_VERSION_CHECK", "1") + exitCode = cli.Run([]string{"tflint", "--version", "--format=json"}) + if exitCode != cmd.ExitCodeOK { + t.Fatalf("version failed with exit code %d\nstdout: %s\nstderr: %s", exitCode, outStream.String(), errStream.String()) + } + + var output cmd.VersionOutput + if err := json.Unmarshal(outStream.Bytes(), &output); err != nil { + t.Fatalf("failed to unmarshal JSON: %s\noutput: %s", err, outStream.String()) + } + + // Non-recursive mode should NOT have modules field + if len(output.Modules) != 0 { + t.Errorf("non-recursive mode should not have modules, got: %+v", output.Modules) + } + + // Should have plugins field + if len(output.Plugins) != 1 { + t.Errorf("expected 1 plugin, got %d: %+v", len(output.Plugins), output.Plugins) + } + + if output.Plugins[0].Name != "ruleset.aws" || output.Plugins[0].Version != "0.21.1" { + t.Errorf("expected aws plugin 0.21.1, got: %+v", output.Plugins[0]) + } +} + +func TestVersionTextFormat(t *testing.T) { + tmpDir := t.TempDir() + if err := os.WriteFile(filepath.Join(tmpDir, ".tflint.hcl"), []byte{}, 0644); err != nil { + t.Fatal(err) + } + + t.Chdir(tmpDir) + + outStream, errStream := new(bytes.Buffer), new(bytes.Buffer) + cli, err := cmd.NewCLI(outStream, errStream) + if err != nil { + t.Fatal(err) + } + + t.Setenv("TFLINT_DISABLE_VERSION_CHECK", "1") + exitCode := cli.Run([]string{"tflint", "--version"}) + if exitCode != cmd.ExitCodeOK { + t.Fatalf("expected exit code %d, got %d\nstderr: %s", cmd.ExitCodeOK, exitCode, errStream.String()) + } + + output := outStream.String() + if !strings.Contains(output, "TFLint version") { + t.Errorf("output should contain 'TFLint version', got: %s", output) + } +} diff --git a/tflint/config.go b/tflint/config.go index 999c2930d..68e945b3a 100644 --- a/tflint/config.go +++ b/tflint/config.go @@ -160,6 +160,7 @@ func LoadConfig(fs afero.Afero, file string) (*Config, error) { if err != nil { return nil, fmt.Errorf("failed to load file: %w", err) } + defer f.Close() cfg, err := loadConfig(f) if err != nil { return nil, err @@ -175,6 +176,7 @@ func LoadConfig(fs afero.Afero, file string) (*Config, error) { if err != nil { return nil, fmt.Errorf("failed to load file: %w", err) } + defer f.Close() cfg, err := loadConfig(f) if err != nil { return nil, err @@ -185,6 +187,7 @@ func LoadConfig(fs afero.Afero, file string) (*Config, error) { // Load the default config file (prefer .hcl over .json) log.Printf("[INFO] Load config: %s", defaultConfigFile) if f, err := fs.Open(defaultConfigFile); err == nil { + defer f.Close() cfg, err := loadConfig(f) if err != nil { return nil, err @@ -196,6 +199,7 @@ func LoadConfig(fs afero.Afero, file string) (*Config, error) { // Try JSON config file if HCL not found log.Printf("[INFO] Load config: %s", defaultConfigFileJSON) if f, err := fs.Open(defaultConfigFileJSON); err == nil { + defer f.Close() cfg, err := loadConfig(f) if err != nil { return nil, err @@ -211,6 +215,7 @@ func LoadConfig(fs afero.Afero, file string) (*Config, error) { } log.Printf("[INFO] Load config: %s", fallback) if f, err := fs.Open(fallback); err == nil { + defer f.Close() cfg, err := loadConfig(f) if err != nil { return nil, err @@ -226,6 +231,7 @@ func LoadConfig(fs afero.Afero, file string) (*Config, error) { } log.Printf("[INFO] Load config: %s", fallbackJSON) if f, err := fs.Open(fallbackJSON); err == nil { + defer f.Close() cfg, err := loadConfig(f) if err != nil { return nil, err diff --git a/versioncheck/cache.go b/versioncheck/cache.go new file mode 100644 index 000000000..a2b766ea9 --- /dev/null +++ b/versioncheck/cache.go @@ -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 +} diff --git a/versioncheck/cache_test.go b/versioncheck/cache_test.go new file mode 100644 index 000000000..4c1ce4f01 --- /dev/null +++ b/versioncheck/cache_test.go @@ -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) + } + }) + } +} diff --git a/versioncheck/check.go b/versioncheck/check.go new file mode 100644 index 000000000..e676eee94 --- /dev/null +++ b/versioncheck/check.go @@ -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 +} diff --git a/versioncheck/check_test.go b/versioncheck/check_test.go new file mode 100644 index 000000000..a5390e95c --- /dev/null +++ b/versioncheck/check_test.go @@ -0,0 +1,151 @@ +package versioncheck + +import ( + "testing" + + "github.com/hashicorp/go-version" +) + +func TestEnabled(t *testing.T) { + tests := []struct { + name string + envValue string + want bool + }{ + { + name: "not set - enabled by default", + envValue: "", + want: true, + }, + { + name: "disabled", + envValue: "1", + want: false, + }, + { + name: "invalid value - enabled by default", + envValue: "invalid", + want: true, + }, + { + name: "disabled with 0", + envValue: "0", + want: true, + }, + { + name: "disabled with true", + envValue: "true", + want: false, + }, + { + name: "disabled with True", + envValue: "True", + want: false, + }, + { + name: "disabled with TRUE", + envValue: "TRUE", + want: false, + }, + { + name: "enabled with false", + envValue: "false", + want: true, + }, + { + name: "enabled with False", + envValue: "False", + want: true, + }, + { + name: "enabled with FALSE", + envValue: "FALSE", + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.envValue != "" { + t.Setenv("TFLINT_DISABLE_VERSION_CHECK", tt.envValue) + } + + got := Enabled() + if got != tt.want { + t.Errorf("Enabled() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestCompareVersions(t *testing.T) { + tests := []struct { + name string + current string + latest string + wantAvailable bool + wantError bool + }{ + { + name: "update available", + current: "0.59.0", + latest: "0.60.0", + wantAvailable: true, + wantError: false, + }, + { + name: "v prefix stripped", + current: "0.59.0", + latest: "v0.60.0", + wantAvailable: true, + wantError: false, + }, + { + name: "already latest", + current: "0.60.0", + latest: "0.60.0", + wantAvailable: false, + wantError: false, + }, + { + name: "invalid latest version", + current: "0.60.0", + latest: "invalid", + wantAvailable: false, + wantError: true, + }, + { + name: "current newer than latest", + current: "0.61.0", + latest: "0.60.0", + wantAvailable: false, + wantError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current := version.Must(version.NewVersion(tt.current)) + + got, err := compareVersions(current, tt.latest) + if (err != nil) != tt.wantError { + t.Errorf("compareVersions() error = %v, wantError %v", err, tt.wantError) + return + } + + if !tt.wantError { + if got.Available != tt.wantAvailable { + t.Errorf("compareVersions() Available = %v, want %v", got.Available, tt.wantAvailable) + } + + expectedLatest := tt.latest + if len(expectedLatest) > 0 && expectedLatest[0] == 'v' { + expectedLatest = expectedLatest[1:] + } + if got.Latest != expectedLatest { + t.Errorf("compareVersions() Latest = %v, want %v", got.Latest, expectedLatest) + } + } + }) + } +} diff --git a/versioncheck/github.go b/versioncheck/github.go new file mode 100644 index 000000000..b70fb6186 --- /dev/null +++ b/versioncheck/github.go @@ -0,0 +1,56 @@ +package versioncheck + +import ( + "context" + "fmt" + "log" + "net/http" + "os" + + "github.com/google/go-github/v67/github" + "golang.org/x/oauth2" +) + +const ( + repoOwner = "terraform-linters" + repoName = "tflint" +) + +// fetchLatestRelease fetches the latest release version from GitHub +func fetchLatestRelease(ctx context.Context) (string, error) { + return fetchLatestReleaseWithClient(ctx, nil) +} + +// fetchLatestReleaseWithClient fetches the latest release version from GitHub using a custom HTTP client +// If httpClient is nil, creates a default client with optional GITHUB_TOKEN authentication +func fetchLatestReleaseWithClient(ctx context.Context, httpClient *http.Client) (string, error) { + // Create GitHub client with optional authentication + if httpClient == nil { + httpClient = &http.Client{Transport: http.DefaultTransport} + if token := os.Getenv("GITHUB_TOKEN"); token != "" { + httpClient = oauth2.NewClient(ctx, oauth2.StaticTokenSource(&oauth2.Token{ + AccessToken: token, + })) + } + } + client := github.NewClient(httpClient) + + log.Printf("[DEBUG] Fetching latest release from GitHub API") + release, resp, err := client.Repositories.GetLatestRelease(ctx, repoOwner, repoName) + if err != nil { + // Check if it's a rate limit error + if resp != nil && resp.StatusCode == http.StatusForbidden { + if resp.Rate.Remaining == 0 { + log.Printf("[ERROR] GitHub API rate limited, consider setting GITHUB_TOKEN") + } + } + return "", fmt.Errorf("failed to fetch latest release: %w", err) + } + + if release.TagName == nil { + return "", fmt.Errorf("latest release has no tag name") + } + + log.Printf("[DEBUG] Latest release: %s", *release.TagName) + return *release.TagName, nil +} diff --git a/versioncheck/github_test.go b/versioncheck/github_test.go new file mode 100644 index 000000000..621706b02 --- /dev/null +++ b/versioncheck/github_test.go @@ -0,0 +1,117 @@ +package versioncheck + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "testing" + + "github.com/google/go-github/v67/github" +) + +func TestFetchLatestReleaseWithClient(t *testing.T) { + tests := []struct { + name string + statusCode int + response interface{} + wantErr bool + wantVersion string + checkReq func(*testing.T, *http.Request) + }{ + { + name: "successful fetch", + statusCode: http.StatusOK, + response: &github.RepositoryRelease{ + TagName: github.String("v0.60.0"), + }, + wantVersion: "v0.60.0", + }, + { + name: "missing tag name", + statusCode: http.StatusOK, + response: &github.RepositoryRelease{ + TagName: nil, + }, + wantErr: true, + }, + { + name: "rate limit error", + statusCode: http.StatusForbidden, + response: map[string]interface{}{ + "message": "API rate limit exceeded", + }, + wantErr: true, + }, + { + name: "not found error", + statusCode: http.StatusNotFound, + response: map[string]interface{}{ + "message": "Not Found", + }, + wantErr: true, + }, + { + name: "server error", + statusCode: http.StatusInternalServerError, + response: map[string]interface{}{ + "message": "Internal Server Error", + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if tt.checkReq != nil { + tt.checkReq(t, r) + } + + w.WriteHeader(tt.statusCode) + if tt.response != nil { + _ = json.NewEncoder(w).Encode(tt.response) + } + })) + defer server.Close() + + // Create a custom client that points to our test server + testClient := &http.Client{ + Transport: &testTransport{ + baseURL: server.URL, + }, + } + + ctx := context.Background() + got, err := fetchLatestReleaseWithClient(ctx, testClient) + + if (err != nil) != tt.wantErr { + t.Errorf("fetchLatestReleaseWithClient() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if !tt.wantErr && got != tt.wantVersion { + t.Errorf("fetchLatestReleaseWithClient() = %v, want %v", got, tt.wantVersion) + } + }) + } +} + +// testTransport is a custom RoundTripper that redirects requests to a test server +type testTransport struct { + baseURL string +} + +func (t *testTransport) RoundTrip(req *http.Request) (*http.Response, error) { + // Rewrite the request URL to point to our test server + testURL, err := url.Parse(t.baseURL) + if err != nil { + return nil, err + } + + req.URL.Scheme = testURL.Scheme + req.URL.Host = testURL.Host + + return http.DefaultTransport.RoundTrip(req) +}