diff --git a/.gitignore b/.gitignore index f231eff31..7eeb05d79 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,5 @@ # Nix .envrc .direnv/ + +.atmos/cache.yaml diff --git a/atmos.yaml b/atmos.yaml index adfd5176b..e9793b62f 100644 --- a/atmos.yaml +++ b/atmos.yaml @@ -315,3 +315,9 @@ settings: # If the source and destination lists have the same length, all items in the destination lists are # deep-merged with all items in the source list. list_merge_strategy: replace + +version: + check: + enabled: true + timeout: 1000 # ms + frequency: 1h diff --git a/cmd/cmd_utils.go b/cmd/cmd_utils.go index 3d55bf630..149969cf6 100644 --- a/cmd/cmd_utils.go +++ b/cmd/cmd_utils.go @@ -7,6 +7,7 @@ import ( "os" "path" "strings" + "time" "github.com/fatih/color" "github.com/spf13/cobra" @@ -428,18 +429,60 @@ func printMessageForMissingAtmosConfig(cliConfig schema.CliConfiguration) { u.PrintMessage("https://atmos.tools/quick-start\n") } -// customHelpMessageToUpgradeToAtmosLatestRelease adds Atmos version info at the end of each help commnad -func customHelpMessageToUpgradeToAtmosLatestRelease(cmd *cobra.Command, args []string) { - originalHelpFunc(cmd, args) - // Check for the latest Atmos release on GitHub +// CheckForAtmosUpdateAndPrintMessage checks if a version update is needed and prints a message if a newer version is found. +// It loads the cache, decides if it's time to check for updates, compares the current version to the latest available release, +// and if newer, prints the update message. It also updates the cache's timestamp after printing. +func CheckForAtmosUpdateAndPrintMessage(cliConfig schema.CliConfiguration) { + // If version checking is disabled in the configuration, do nothing + if !cliConfig.Version.Check.Enabled { + return + } + + // Load the cache + cacheCfg, err := cfg.LoadCache() + if err != nil { + u.LogWarning(cliConfig, fmt.Sprintf("Could not load cache: %s", err)) + return + } + + // Determine if it's time to check for updates based on frequency and last_checked + if !cfg.ShouldCheckForUpdates(cacheCfg.LastChecked, cliConfig.Version.Check.Frequency) { + // Not due for another check yet, so return without printing anything + return + } + + // Get the latest Atmos release from GitHub latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { - u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) - } + if err != nil { + u.LogWarning(cliConfig, fmt.Sprintf("Failed to retrieve latest Atmos release info: %s", err)) + return } + + if latestReleaseTag == "" { + u.LogWarning(cliConfig, "No release information available") + return + } + + // Trim "v" prefix to compare versions + latestVersion := strings.TrimPrefix(latestReleaseTag, "v") + currentVersion := strings.TrimPrefix(version.Version, "v") + + // If the versions differ, print the update message + if latestVersion != currentVersion { + u.PrintMessageToUpgradeToAtmosLatestRelease(latestVersion) + } + + // Update the cache to mark the current timestamp + cacheCfg.LastChecked = time.Now().Unix() + if saveErr := cfg.SaveCache(cacheCfg); saveErr != nil { + u.LogWarning(cliConfig, fmt.Sprintf("Unable to save cache: %s", saveErr)) + + } +} + +func customHelpMessageToUpgradeToAtmosLatestRelease(cmd *cobra.Command, args []string) { + originalHelpFunc(cmd, args) + CheckForAtmosUpdateAndPrintMessage(cliConfig) } // Check Atmos is version command diff --git a/cmd/docs.go b/cmd/docs.go index c9cad03a2..93a46b77d 100644 --- a/cmd/docs.go +++ b/cmd/docs.go @@ -97,7 +97,10 @@ var docsCmd = &cobra.Command{ u.LogErrorAndExit(schema.CliConfiguration{}, err) } - fmt.Println(componentDocs) + if err := u.DisplayDocs(componentDocs, cliConfig.Settings.Docs.Pagination); err != nil { + u.LogErrorAndExit(schema.CliConfiguration{}, fmt.Errorf("failed to display documentation: %w", err)) + } + return } diff --git a/cmd/helmfile.go b/cmd/helmfile.go index e4fb07c84..eb5ee9663 100644 --- a/cmd/helmfile.go +++ b/cmd/helmfile.go @@ -1,15 +1,12 @@ package cmd import ( - "strings" - "github.com/samber/lo" "github.com/spf13/cobra" e "github.com/cloudposse/atmos/internal/exec" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" - "github.com/cloudposse/atmos/pkg/version" ) // helmfileCmd represents the base command for all helmfile sub-commands @@ -34,18 +31,10 @@ var helmfileCmd = &cobra.Command{ if err != nil { u.LogErrorAndExit(schema.CliConfiguration{}, err) } - - // Check for the latest Atmos release on GitHub and print update message - latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { - u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) - } - } // Exit on help if info.NeedHelp { + // Check for the latest Atmos release on GitHub and print update message + CheckForAtmosUpdateAndPrintMessage(cliConfig) return } // Check Atmos configuration diff --git a/cmd/root.go b/cmd/root.go index 0027776c1..c7b043a3f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -18,6 +18,8 @@ import ( u "github.com/cloudposse/atmos/pkg/utils" ) +var cliConfig schema.CliConfiguration + // originalHelpFunc holds Cobra's original help function to avoid recursion. var originalHelpFunc func(*cobra.Command, []string) @@ -72,14 +74,6 @@ func Execute() error { Flags: cc.Bold, }) - // Save the original help function to prevent infinite recursion when overriding it. - // This allows us to call the original help functionality within our custom help function. - originalHelpFunc = RootCmd.HelpFunc() - - // Override the help function with a custom one that adds an upgrade message after displaying help. - // This custom help function will call the original help function and then display the bordered message. - RootCmd.SetHelpFunc(customHelpMessageToUpgradeToAtmosLatestRelease) - // Check if the `help` flag is passed and print a styled Atmos logo to the terminal before printing the help err := RootCmd.ParseFlags(os.Args) if err != nil && errors.Is(err, pflag.ErrHelp) { @@ -89,21 +83,29 @@ func Execute() error { u.LogErrorAndExit(schema.CliConfiguration{}, err) } } - // InitCliConfig finds and merges CLI configurations in the following order: // system dir, home dir, current dir, ENV vars, command-line arguments // Here we need the custom commands from the config - cliConfig, err := cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) - if err != nil && !errors.Is(err, cfg.NotFound) { + var initErr error + cliConfig, initErr = cfg.InitCliConfig(schema.ConfigAndStacksInfo{}, false) + if initErr != nil && !errors.Is(initErr, cfg.NotFound) { if isVersionCommand() { - u.LogTrace(schema.CliConfiguration{}, fmt.Sprintf("warning: CLI configuration 'atmos.yaml' file not found. Error: %s", err)) + u.LogTrace(schema.CliConfiguration{}, fmt.Sprintf("warning: CLI configuration 'atmos.yaml' file not found. Error: %s", initErr)) } else { - u.LogErrorAndExit(schema.CliConfiguration{}, err) + u.LogErrorAndExit(schema.CliConfiguration{}, initErr) } } + // Save the original help function to prevent infinite recursion when overriding it. + // This allows us to call the original help functionality within our custom help function. + originalHelpFunc = RootCmd.HelpFunc() + + // Override the help function with a custom one that adds an upgrade message after displaying help. + // This custom help function will call the original help function and then display the bordered message. + RootCmd.SetHelpFunc(customHelpMessageToUpgradeToAtmosLatestRelease) + // If CLI configuration was found, process its custom commands and command aliases - if err == nil { + if initErr == nil { err = processCustomCommands(cliConfig, cliConfig.Commands, RootCmd, true) if err != nil { u.LogErrorAndExit(schema.CliConfiguration{}, err) diff --git a/cmd/terraform.go b/cmd/terraform.go index 5d8832c1b..cc9837bb4 100644 --- a/cmd/terraform.go +++ b/cmd/terraform.go @@ -1,15 +1,12 @@ package cmd import ( - "strings" - "github.com/samber/lo" "github.com/spf13/cobra" e "github.com/cloudposse/atmos/internal/exec" "github.com/cloudposse/atmos/pkg/schema" u "github.com/cloudposse/atmos/pkg/utils" - "github.com/cloudposse/atmos/pkg/version" ) // terraformCmd represents the base command for all terraform sub-commands @@ -35,17 +32,11 @@ var terraformCmd = &cobra.Command{ if err != nil { u.LogErrorAndExit(schema.CliConfiguration{}, err) } - // Check for the latest Atmos release on GitHub and print update message - latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { - u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) - } - } + // Exit on help if info.NeedHelp { + // Check for the latest Atmos release on GitHub and print update message + CheckForAtmosUpdateAndPrintMessage(cliConfig) return } // Check Atmos configuration diff --git a/cmd/version.go b/cmd/version.go index 3665077c6..1e13eadee 100644 --- a/cmd/version.go +++ b/cmd/version.go @@ -13,6 +13,8 @@ import ( "github.com/cloudposse/atmos/pkg/version" ) +var checkFlag bool + var versionCmd = &cobra.Command{ Use: "version", Short: "Show the installed Atmos CLI version and check for updates", @@ -29,18 +31,33 @@ var versionCmd = &cobra.Command{ u.PrintMessage(fmt.Sprintf("\U0001F47D Atmos %s on %s/%s", version.Version, runtime.GOOS, runtime.GOARCH)) fmt.Println() - // Check for the latest Atmos release on GitHub - latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") - if err == nil && latestReleaseTag != "" { - latestRelease := strings.TrimPrefix(latestReleaseTag, "v") - currentRelease := strings.TrimPrefix(version.Version, "v") - if latestRelease != currentRelease { - u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) + if checkFlag { + // Check for the latest Atmos release on GitHub + latestReleaseTag, err := u.GetLatestGitHubRepoRelease("cloudposse", "atmos") + if err == nil && latestReleaseTag != "" { + if err != nil { + u.LogWarning(schema.CliConfiguration{}, fmt.Sprintf("Failed to check for updates: %v", err)) + return + } + if latestReleaseTag == "" { + u.LogWarning(schema.CliConfiguration{}, "No release information available") + return + } + latestRelease := strings.TrimPrefix(latestReleaseTag, "v") + currentRelease := strings.TrimPrefix(version.Version, "v") + if latestRelease != currentRelease { + u.PrintMessageToUpgradeToAtmosLatestRelease(latestRelease) + } } + return } + + // Check for the cache and print update message + CheckForAtmosUpdateAndPrintMessage(cliConfig) }, } func init() { + versionCmd.Flags().BoolVarP(&checkFlag, "check", "c", false, "Run additional checks after displaying version info") RootCmd.AddCommand(versionCmd) } diff --git a/examples/quick-start-advanced/Dockerfile b/examples/quick-start-advanced/Dockerfile index 8ad376f7e..895f09c56 100644 --- a/examples/quick-start-advanced/Dockerfile +++ b/examples/quick-start-advanced/Dockerfile @@ -6,7 +6,7 @@ ARG GEODESIC_OS=debian # https://atmos.tools/ # https://github.com/cloudposse/atmos # https://github.com/cloudposse/atmos/releases -ARG ATMOS_VERSION=1.122.0 +ARG ATMOS_VERSION=1.127.0 # Terraform: https://github.com/hashicorp/terraform/releases ARG TF_VERSION=1.5.7 diff --git a/go.mod b/go.mod index ef51bcbdf..1c01ef148 100644 --- a/go.mod +++ b/go.mod @@ -16,6 +16,7 @@ require ( github.com/elewis787/boa v0.1.2 github.com/fatih/color v1.18.0 github.com/go-git/go-git/v5 v5.12.0 + github.com/gofrs/flock v0.12.1 github.com/google/go-containerregistry v0.20.2 github.com/google/go-github/v59 v59.0.0 github.com/google/uuid v1.6.0 diff --git a/go.sum b/go.sum index f557e9bc6..90b091ecb 100644 --- a/go.sum +++ b/go.sum @@ -584,6 +584,8 @@ github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22 github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/gofrs/flock v0.12.1 h1:MTLVXXHf8ekldpJk3AKicLij9MdwOWkZ+a/jHHZby9E= +github.com/gofrs/flock v0.12.1/go.mod h1:9zxTsyu5xtJ9DK+1tFZyibEV7y3uwDxPPfbxeeHCoD0= github.com/gofrs/uuid v4.0.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ= github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= diff --git a/internal/exec/atmos.go b/internal/exec/atmos.go index 446c42ca1..a7f5f6f7d 100644 --- a/internal/exec/atmos.go +++ b/internal/exec/atmos.go @@ -51,7 +51,7 @@ func ExecuteAtmosCmd() error { if v2, ok := v.(map[string]any); ok { if v3, ok := v2["components"].(map[string]any); ok { if v4, ok := v3["terraform"].(map[string]any); ok { - return k, lo.Keys(v4) + return k, FilterAbstractComponents(v4) } // TODO: process 'helmfile' components and stacks. // This will require checking the list of commands and filtering the stacks and components depending on the selected command. diff --git a/internal/exec/describe_component.go b/internal/exec/describe_component.go index 2fabc2611..5afea0dd7 100644 --- a/internal/exec/describe_component.go +++ b/internal/exec/describe_component.go @@ -2,6 +2,7 @@ package exec import ( "github.com/pkg/errors" + "github.com/samber/lo" "github.com/spf13/cobra" cfg "github.com/cloudposse/atmos/pkg/config" @@ -83,3 +84,32 @@ func ExecuteDescribeComponent( return configAndStacksInfo.ComponentSection, nil } + +// FilterAbstractComponents This function removes abstract components and returns the list of components +func FilterAbstractComponents(componentsMap map[string]any) []string { + if componentsMap == nil { + return []string{} + } + components := make([]string, 0) + for _, k := range lo.Keys(componentsMap) { + componentMap, ok := componentsMap[k].(map[string]any) + if !ok { + components = append(components, k) + continue + } + + metadata, ok := componentMap["metadata"].(map[string]any) + if !ok { + components = append(components, k) + continue + } + if componentType, ok := metadata["type"].(string); ok && componentType == "abstract" { + continue + } + if componentEnabled, ok := metadata["enabled"].(bool); ok && !componentEnabled { + continue + } + components = append(components, k) + } + return components +} diff --git a/internal/exec/helmfile.go b/internal/exec/helmfile.go index bfcfde41a..fe36f7e9f 100644 --- a/internal/exec/helmfile.go +++ b/internal/exec/helmfile.go @@ -54,6 +54,10 @@ func ExecuteHelmfile(info schema.ConfigAndStacksInfo) error { return nil } + if info.SubCommand == "version" { + return ExecuteShellCommand(cliConfig, "helmfile", []string{info.SubCommand}, "", nil, false, info.RedirectStdErr) + } + info, err = ProcessStacks(cliConfig, info, true, true) if err != nil { return err diff --git a/internal/exec/terraform.go b/internal/exec/terraform.go index c0d53700d..88fd45b04 100644 --- a/internal/exec/terraform.go +++ b/internal/exec/terraform.go @@ -61,6 +61,15 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo) error { fmt.Println() return nil } + if info.SubCommand == "version" { + return ExecuteShellCommand(cliConfig, + "terraform", + []string{info.SubCommand}, + "", + nil, + false, + info.RedirectStdErr) + } shouldProcessStacks := true shouldCheckStack := true @@ -478,7 +487,7 @@ func ExecuteTerraform(info schema.ConfigAndStacksInfo) error { } // Clean up - if info.SubCommand != "plan" && info.PlanFile == "" { + if info.SubCommand != "plan" && info.SubCommand != "show" && info.PlanFile == "" { planFilePath := constructTerraformComponentPlanfilePath(cliConfig, info) _ = os.Remove(planFilePath) } diff --git a/internal/exec/utils.go b/internal/exec/utils.go index 6fefa53ba..46693a4ba 100644 --- a/internal/exec/utils.go +++ b/internal/exec/utils.go @@ -661,22 +661,19 @@ func processArgsAndFlags(componentType string, inputArgsAndFlags []string) (sche var indexesToRemove []int // For commands like `atmos terraform clean` and `atmos terraform plan`, show the command help - if len(inputArgsAndFlags) == 1 { + if len(inputArgsAndFlags) == 1 && inputArgsAndFlags[0] != "version" { info.SubCommand = inputArgsAndFlags[0] info.NeedHelp = true return info, nil } - - // https://github.com/roboll/helmfile#cli-reference - var globalOptionsFlagIndex int - - // For commands like `atmos terraform clean` and `atmos terraform plan`, show the command help - if len(inputArgsAndFlags) == 1 { + if len(inputArgsAndFlags) == 1 && inputArgsAndFlags[0] == "version" { info.SubCommand = inputArgsAndFlags[0] - info.NeedHelp = true return info, nil } + // https://github.com/roboll/helmfile#cli-reference + var globalOptionsFlagIndex int + for i, arg := range inputArgsAndFlags { if arg == cfg.GlobalOptionsFlag { globalOptionsFlagIndex = i + 1 diff --git a/pkg/config/cache.go b/pkg/config/cache.go new file mode 100644 index 000000000..6833e67b5 --- /dev/null +++ b/pkg/config/cache.go @@ -0,0 +1,164 @@ +package config + +import ( + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/gofrs/flock" + "github.com/pkg/errors" + "github.com/spf13/viper" + + "github.com/cloudposse/atmos/pkg/schema" + u "github.com/cloudposse/atmos/pkg/utils" +) + +type CacheConfig struct { + LastChecked int64 `mapstructure:"last_checked"` +} + +func GetCacheFilePath() (string, error) { + xdgCacheHome := os.Getenv("XDG_CACHE_HOME") + var cacheDir string + if xdgCacheHome == "" { + cacheDir = filepath.Join(".", ".atmos") + } else { + cacheDir = filepath.Join(xdgCacheHome, "atmos") + } + + if err := os.MkdirAll(cacheDir, 0755); err != nil { + return "", errors.Wrap(err, "error creating cache directory") + } + + return filepath.Join(cacheDir, "cache.yaml"), nil +} + +func withCacheFileLock(cacheFile string, fn func() error) error { + lock := flock.New(cacheFile) + err := lock.Lock() + if err != nil { + return errors.Wrap(err, "error acquiring file lock") + } + defer lock.Unlock() + return fn() +} + +func LoadCache() (CacheConfig, error) { + cacheFile, err := GetCacheFilePath() + if err != nil { + return CacheConfig{}, err + } + + var cfg CacheConfig + if _, err := os.Stat(cacheFile); os.IsNotExist(err) { + // No file yet, return default + return cfg, nil + } + + v := viper.New() + v.SetConfigFile(cacheFile) + if err := v.ReadInConfig(); err != nil { + return cfg, errors.Wrap(err, "failed to read cache file") + } + if err := v.Unmarshal(&cfg); err != nil { + return cfg, errors.Wrap(err, "failed to unmarshal cache file") + } + return cfg, nil +} + +func SaveCache2(cfg CacheConfig) error { + cacheFile, err := GetCacheFilePath() + if err != nil { + return err + } + + return withCacheFileLock(cacheFile, func() error { + v := viper.New() + v.Set("last_checked", cfg.LastChecked) + if err := v.WriteConfigAs(cacheFile); err != nil { + return errors.Wrap(err, "failed to write cache file") + } + return nil + }) +} + +func SaveCache(cfg CacheConfig) error { + cacheFile, err := GetCacheFilePath() + if err != nil { + return err + } + + v := viper.New() + v.Set("last_checked", cfg.LastChecked) + if err := v.WriteConfigAs(cacheFile); err != nil { + return errors.Wrap(err, "failed to write cache file") + } + return nil +} + +func ShouldCheckForUpdates(lastChecked int64, frequency string) bool { + now := time.Now().Unix() + + interval, err := parseFrequency(frequency) + if err != nil { + // Log warning and default to daily if we can’t parse + u.LogWarning(schema.CliConfiguration{}, fmt.Sprintf("Unsupported frequency '%s' encountered. Defaulting to daily.", frequency)) + interval = 86400 // daily + } + return now-lastChecked >= interval +} + +// parseFrequency attempts to parse the frequency string in three ways: +// 1. As an integer (seconds) +// 2. As a duration with a suffix (e.g., "1h", "5m", "30s") +// 3. As one of the predefined keywords (daily, hourly, etc.) +func parseFrequency(frequency string) (int64, error) { + freq := strings.TrimSpace(frequency) + + if intVal, err := strconv.ParseInt(freq, 10, 64); err == nil { + if intVal > 0 { + return intVal, nil + } + } + + // Parse duration with suffix + if len(freq) > 1 { + unit := freq[len(freq)-1] + valPart := freq[:len(freq)-1] + if valInt, err := strconv.ParseInt(valPart, 10, 64); err == nil && valInt > 0 { + switch unit { + case 's': + return valInt, nil + case 'm': + return valInt * 60, nil + case 'h': + return valInt * 3600, nil + case 'd': + return valInt * 86400, nil + default: + return 0, fmt.Errorf("unrecognized duration unit: %s", string(unit)) + } + } + } + + // Handle predefined keywords + switch freq { + case "minute": + return 60, nil + case "hourly": + return 3600, nil + case "daily": + return 86400, nil + case "weekly": + return 604800, nil + case "monthly": + return 2592000, nil + case "yearly": + return 31536000, nil + default: + return 0, fmt.Errorf("unrecognized frequency: %s", freq) + } +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 8cbfb8c36..a0a1f0167 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -82,6 +82,13 @@ var ( }, }, Initialized: true, + Version: schema.Version{ + Check: schema.VersionCheck{ + Enabled: true, + Timeout: 1000, + Frequency: "daily", + }, + }, } ) diff --git a/pkg/config/utils.go b/pkg/config/utils.go index 929243c3f..060a1cf2a 100644 --- a/pkg/config/utils.go +++ b/pkg/config/utils.go @@ -371,6 +371,17 @@ func processEnvVars(cliConfig *schema.CliConfiguration) error { cliConfig.Settings.ListMergeStrategy = listMergeStrategy } + versionEnabled := os.Getenv("ATMOS_VERSION_CHECK_ENABLED") + if len(versionEnabled) > 0 { + u.LogTrace(*cliConfig, fmt.Sprintf("Found ENV var ATMOS_VERSION_CHECK_ENABLED=%s", versionEnabled)) + enabled, err := strconv.ParseBool(versionEnabled) + if err != nil { + u.LogWarning(*cliConfig, fmt.Sprintf("Invalid boolean value '%s' for ATMOS_VERSION_CHECK_ENABLED; using default.", versionEnabled)) + } else { + cliConfig.Version.Check.Enabled = enabled + } + } + return nil } diff --git a/pkg/schema/schema.go b/pkg/schema/schema.go index 7eacc76ff..e44396a38 100644 --- a/pkg/schema/schema.go +++ b/pkg/schema/schema.go @@ -26,6 +26,7 @@ type CliConfiguration struct { StackConfigFilesAbsolutePaths []string `yaml:"stackConfigFilesAbsolutePaths,omitempty" json:"stackConfigFilesAbsolutePaths,omitempty" mapstructure:"stackConfigFilesAbsolutePaths"` StackType string `yaml:"stackType,omitempty" json:"StackType,omitempty" mapstructure:"stackType"` Default bool `yaml:"default" json:"default" mapstructure:"default"` + Version Version `yaml:"version,omitempty" json:"version,omitempty" mapstructure:"version"` } type CliSettings struct { @@ -34,7 +35,8 @@ type CliSettings struct { } type Docs struct { - MaxWidth int `yaml:"max-width" json:"max_width" mapstructure:"max-width"` + MaxWidth int `yaml:"max-width" json:"max_width" mapstructure:"max-width"` + Pagination bool `yaml:"pagination" json:"pagination" mapstructure:"pagination"` } type Templates struct { @@ -127,6 +129,16 @@ type Context struct { TerraformWorkspace string `yaml:"terraform_workspace" json:"terraform_workspace" mapstructure:"terraform_workspace"` } +type VersionCheck struct { + Enabled bool `yaml:"enabled,omitempty" mapstructure:"enabled"` + Timeout int `yaml:"timeout,omitempty" mapstructure:"timeout"` + Frequency string `yaml:"frequency,omitempty" mapstructure:"frequency"` +} + +type Version struct { + Check VersionCheck `yaml:"check,omitempty" mapstructure:"check"` +} + type ArgsAndFlagsInfo struct { AdditionalArgsAndFlags []string SubCommand string diff --git a/pkg/utils/doc_utils.go b/pkg/utils/doc_utils.go new file mode 100644 index 000000000..a8d302acc --- /dev/null +++ b/pkg/utils/doc_utils.go @@ -0,0 +1,39 @@ +package utils + +import ( + "fmt" + "os" + "os/exec" + "strings" +) + +// DisplayDocs displays component documentation directly through the terminal or +// through a pager (like less). The use of a pager is determined by the pagination value +// set in the CLI Settings for Atmos +func DisplayDocs(componentDocs string, usePager bool) error { + if !usePager { + fmt.Println(componentDocs) + return nil + } + + pagerCmd := os.Getenv("PAGER") + if pagerCmd == "" { + pagerCmd = "less -r" + } + + args := strings.Fields(pagerCmd) + if len(args) == 0 { + return fmt.Errorf("invalid pager command") + } + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = strings.NewReader(componentDocs) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + + if err := cmd.Run(); err != nil { + return fmt.Errorf("failed to execute pager: %w", err) + } + + return nil +} diff --git a/pkg/utils/json_utils.go b/pkg/utils/json_utils.go index aeb8ad4b6..d9e9c53fc 100644 --- a/pkg/utils/json_utils.go +++ b/pkg/utils/json_utils.go @@ -51,6 +51,13 @@ func WriteToFileAsJSON(filePath string, data any, fileMode os.FileMode) error { return err } + const newlineByte = '\n' + + // Ensure that the JSON content ends with a newline + if len(indentedJSON) == 0 || indentedJSON[len(indentedJSON)-1] != newlineByte { + indentedJSON = append(indentedJSON, newlineByte) + } + err = os.WriteFile(filePath, indentedJSON, fileMode) if err != nil { return err diff --git a/website/docs/cli/commands/help.mdx b/website/docs/cli/commands/help.mdx index fcbc1c2e3..1df2ef3a1 100644 --- a/website/docs/cli/commands/help.mdx +++ b/website/docs/cli/commands/help.mdx @@ -13,6 +13,10 @@ import Terminal from '@site/src/components/Terminal' The `atmos --help` and `atmos -h` commands show help for all Atmos CLI commands. +From time to time, Atmos will check for a newer release and let you know if one is available. +Please see the [`atmos version`](/cli/commands/version) documentation to configure this behavior. + + ```shell atmos help atmos --help diff --git a/website/docs/cli/commands/version.mdx b/website/docs/cli/commands/version.mdx index a8aabaf56..2deb20a4f 100644 --- a/website/docs/cli/commands/version.mdx +++ b/website/docs/cli/commands/version.mdx @@ -20,7 +20,27 @@ Execute the `atmos version` command like this: atmos version ``` -This will show the CLI version. +This will show the CLI version. + +From time to time, Atmos will check for updates. The frequency of these checks is configured in the `atmos.yaml` file. + +Atmos supports three ways to specify the update check frequency: + +1. As an integer: Specify the number of seconds between checks (for example, 3600 for hourly checks). +2. As a duration with a suffix: Use a time suffix to indicate the interval (for example, `1m` for one minute, `5h` for five hours, or `2d` for two days). +3. As one of the predefined keywords: Choose from the following options: minute, hourly, daily, weekly, monthly, and yearly. The default is daily. +The default is to check `daily`, and if any unsupported values are passed this default will be used. + +It is also possible to turn off version checks in `atmos.yaml` by setting `version.check.enabled` to `false`, +or by setting the `ATMOS_VERSION_CHECK_ENABLED` environment variable to `false`, which overrides +the `version.check.enabled` settings in `atmos.yaml`. + +```shell +atmos version --check +``` + +The version command supports a `--check` flag. +This will force Atmos to check for a new version, irrespective of the configuration settings. :::tip To find the latest version of Atmos, go to the [releases](https://github.com/cloudposse/atmos/releases) page on GitHub. diff --git a/website/docs/cli/configuration/configuration.mdx b/website/docs/cli/configuration/configuration.mdx index bcc4761e7..9268c0cb3 100644 --- a/website/docs/cli/configuration/configuration.mdx +++ b/website/docs/cli/configuration/configuration.mdx @@ -151,6 +151,13 @@ The `settings` section configures Atmos global settings. # If the source and destination lists have the same length, all items in the destination lists are # deep-merged with all items in the source list. list_merge_strategy: replace + # `docs` specifies how component documentation is displayed in the terminal. + # The following documentation display settings are supported: + # `max-width`: The maximum width for displaying component documentation in the terminal. + # 'pagination`: When enabled, displays component documentation in a pager instead of directly in the terminal. + docs: + max-width: 80 + pagination: true ``` @@ -171,9 +178,21 @@ The `settings` section configures Atmos global settings.
The items in the destination list are deep-merged with the items in the source list. The items in the source list take precedence. The items are processed starting from the first up to the length of the source list (the remaining items are not processed). If the source and destination lists have the same length, all items in the destination lists are deep-merged with all items in the source list.
- + +
`settings.docs`
+
+ Specifies how component documentation is displayed in the terminal. + The following settings are supported: +
+
`max-width`
+
The maximum width for displaying component documentation in the terminal.
+
`pagination`
+
When enabled, displays component documentation in a pager instead of directly in the terminal.
+
+
+ ## Workflows @@ -633,6 +652,7 @@ setting `ATMOS_STACKS_BASE_PATH` to a path in `/localhost` to your local develop | ATMOS_LOGS_FILE | logs.file | The file to write Atmos logs to. Logs can be written to any file or any standard file descriptor, including `/dev/stdout`, `/dev/stderr` and `/dev/null`). If omitted, `/dev/stdout` will be used | | ATMOS_LOGS_LEVEL | logs.level | Logs level. Supported log levels are `Trace`, `Debug`, `Info`, `Warning`, `Off`. If the log level is set to `Off`, Atmos will not log any messages (note that this does not prevent other tools like Terraform from logging) | | ATMOS_SETTINGS_LIST_MERGE_STRATEGY | settings.list_merge_strategy | Specifies how lists are merged in Atmos stack manifests. The following strategies are supported: `replace`, `append`, `merge` | +| ATMOS_VERSION_CHECK_ENABLED | version.check.enabled | Enable/disable Atmos version checks for updates to the newest release | ### Context diff --git a/website/docs/integrations/atlantis.mdx b/website/docs/integrations/atlantis.mdx index 842404516..e3a544497 100644 --- a/website/docs/integrations/atlantis.mdx +++ b/website/docs/integrations/atlantis.mdx @@ -673,7 +673,7 @@ on: branches: [ main ] env: - ATMOS_VERSION: 1.122.0 + ATMOS_VERSION: 1.127.0 ATMOS_CLI_CONFIG_PATH: ./ jobs: