diff --git a/tools/cosmovisor/CHANGELOG.md b/tools/cosmovisor/CHANGELOG.md index d3255d8d57db..39b549e882ba 100644 --- a/tools/cosmovisor/CHANGELOG.md +++ b/tools/cosmovisor/CHANGELOG.md @@ -38,6 +38,18 @@ Ref: https://keepachangelog.com/en/1.0.0/ * [#20062](https://github.com/cosmos/cosmos-sdk/pull/20062) Fixed cosmovisor add-upgrade permissions +## Features + +* [#19764](https://github.com/cosmos/cosmos-sdk/issues/19764) Use config file for cosmovisor configuration. + +## Improvements + +* [#19995](https://github.com/cosmos/cosmos-sdk/pull/19995): + * `init command` writes the configuration to the config file only at the default path `DAEMON_HOME/cosmovisor/config.toml`. + * Provide `--cosmovisor-config` flag with value as args to provide the path to the configuration file in the `run` command. `run --cosmovisor-config (other cmds with flags) ...`. + * Add `--cosmovisor-config` flag to provide `config.toml` path to the configuration file in root command used by `add-upgrade` and `config` subcommands. + * `config command` now displays the configuration from the config file if it is provided. If the config file is not provided, it will display the configuration from the environment variables. + ## v1.5.0 - 2023-07-17 ## Features diff --git a/tools/cosmovisor/args.go b/tools/cosmovisor/args.go index 4501e8e7d880..e89112749b30 100644 --- a/tools/cosmovisor/args.go +++ b/tools/cosmovisor/args.go @@ -12,6 +12,9 @@ import ( "strings" "time" + "github.com/pelletier/go-toml/v2" + "github.com/spf13/viper" + "cosmossdk.io/log" "cosmossdk.io/x/upgrade/plan" upgradetypes "cosmossdk.io/x/upgrade/types" @@ -42,26 +45,29 @@ const ( genesisDir = "genesis" upgradesDir = "upgrades" currentLink = "current" + + cfgFileName = "config" + cfgExtension = "toml" ) // Config is the information passed in to control the daemon type Config struct { - Home string - Name string - AllowDownloadBinaries bool - DownloadMustHaveChecksum bool - RestartAfterUpgrade bool - RestartDelay time.Duration - ShutdownGrace time.Duration - PollInterval time.Duration - UnsafeSkipBackup bool - DataBackupPath string - PreupgradeMaxRetries int - DisableLogs bool - ColorLogs bool - TimeFormatLogs string - CustomPreupgrade string - DisableRecase bool + Home string `toml:"daemon_home" mapstructure:"daemon_home"` + Name string `toml:"daemon_name" mapstructure:"daemon_name"` + AllowDownloadBinaries bool `toml:"daemon_allow_download_binaries" mapstructure:"daemon_allow_download_binaries" default:"false"` + DownloadMustHaveChecksum bool `toml:"daemon_download_must_have_checksum" mapstructure:"daemon_download_must_have_checksum" default:"false"` + RestartAfterUpgrade bool `toml:"daemon_restart_after_upgrade" mapstructure:"daemon_restart_after_upgrade" default:"true"` + RestartDelay time.Duration `toml:"daemon_restart_delay" mapstructure:"daemon_restart_delay"` + ShutdownGrace time.Duration `toml:"daemon_shutdown_grace" mapstructure:"daemon_shutdown_grace"` + PollInterval time.Duration `toml:"daemon_poll_interval" mapstructure:"daemon_poll_interval" default:"300ms"` + UnsafeSkipBackup bool `toml:"unsafe_skip_backup" mapstructure:"unsafe_skip_backup" default:"false"` + DataBackupPath string `toml:"daemon_data_backup_dir" mapstructure:"daemon_data_backup_dir"` + PreUpgradeMaxRetries int `toml:"daemon_preupgrade_max_retries" mapstructure:"daemon_preupgrade_max_retries" default:"0"` + DisableLogs bool `toml:"cosmovisor_disable_logs" mapstructure:"cosmovisor_disable_logs" default:"false"` + ColorLogs bool `toml:"cosmovisor_color_logs" mapstructure:"cosmovisor_color_logs" default:"true"` + TimeFormatLogs string `toml:"cosmovisor_timeformat_logs" mapstructure:"cosmovisor_timeformat_logs" default:"kitchen"` + CustomPreUpgrade string `toml:"cosmovisor_custom_preupgrade" mapstructure:"cosmovisor_custom_preupgrade" default:""` + DisableRecase bool `toml:"cosmovisor_disable_recase" mapstructure:"cosmovisor_disable_recase" default:"false"` // currently running upgrade currentUpgrade upgradetypes.Plan @@ -72,6 +78,11 @@ func (cfg *Config) Root() string { return filepath.Join(cfg.Home, rootName) } +// DefaultCfgPath returns the default path to the configuration file. +func (cfg *Config) DefaultCfgPath() string { + return filepath.Join(cfg.Root(), cfgFileName+"."+cfgExtension) +} + // GenesisBin is the path to the genesis binary - must be in place to start manager func (cfg *Config) GenesisBin() string { return filepath.Join(cfg.Root(), genesisDir, "bin", cfg.Name) @@ -145,6 +156,51 @@ func (cfg *Config) CurrentBin() (string, error) { return binpath, nil } +// GetConfigFromFile will read the configuration from the file at the given path. +// If the file path is not provided, it will try to read the configuration from the ENV variables. +// If a file path is provided and ENV variables are set, they will override the values in the file. +func GetConfigFromFile(filePath string) (*Config, error) { + if filePath == "" { + return GetConfigFromEnv() + } + + // ensure the file exist + if _, err := os.Stat(filePath); err != nil { + return nil, fmt.Errorf("config not found: at %s : %w", filePath, err) + } + + // read the configuration from the file + viper.SetConfigFile(filePath) + // load the env variables + // if the env variable is set, it will override the value provided by the config + viper.AutomaticEnv() + + if err := viper.ReadInConfig(); err != nil { + return nil, fmt.Errorf("failed to read config file: %w", err) + } + + cfg := &Config{} + if err := viper.Unmarshal(cfg); err != nil { + return nil, fmt.Errorf("failed to unmarshal configuration: %w", err) + } + + var ( + err error + errs []error + ) + + if cfg.TimeFormatLogs, err = getTimeFormatOption(cfg.TimeFormatLogs); err != nil { + errs = append(errs, err) + } + + errs = append(errs, cfg.validate()...) + if len(errs) > 0 { + return nil, errors.Join(errs...) + } + + return cfg, nil +} + // GetConfigFromEnv will read the environmental variables into a config // and then validate it is reasonable func GetConfigFromEnv() (*Config, error) { @@ -153,7 +209,7 @@ func GetConfigFromEnv() (*Config, error) { Home: os.Getenv(EnvHome), Name: os.Getenv(EnvName), DataBackupPath: os.Getenv(EnvDataBackupPath), - CustomPreupgrade: os.Getenv(EnvCustomPreupgrade), + CustomPreUpgrade: os.Getenv(EnvCustomPreupgrade), } if cfg.DataBackupPath == "" { @@ -220,8 +276,8 @@ func GetConfigFromEnv() (*Config, error) { } } - envPreupgradeMaxRetriesVal := os.Getenv(EnvPreupgradeMaxRetries) - if cfg.PreupgradeMaxRetries, err = strconv.Atoi(envPreupgradeMaxRetriesVal); err != nil && envPreupgradeMaxRetriesVal != "" { + envPreUpgradeMaxRetriesVal := os.Getenv(EnvPreupgradeMaxRetries) + if cfg.PreUpgradeMaxRetries, err = strconv.Atoi(envPreUpgradeMaxRetriesVal); err != nil && envPreUpgradeMaxRetriesVal != "" { errs = append(errs, fmt.Errorf("%s could not be parsed to int: %w", EnvPreupgradeMaxRetries, err)) } @@ -355,6 +411,7 @@ func (cfg *Config) SetCurrentUpgrade(u upgradetypes.Plan) (rerr error) { return err } +// UpgradeInfo returns the current upgrade info func (cfg *Config) UpgradeInfo() (upgradetypes.Plan, error) { if cfg.currentUpgrade.Name != "" { return cfg.currentUpgrade, nil @@ -381,7 +438,7 @@ returnError: return cfg.currentUpgrade, fmt.Errorf("failed to read %q: %w", filename, err) } -// checks and validates env option +// BooleanOption checks and validate env option func BooleanOption(name string, defaultVal bool) (bool, error) { p := strings.ToLower(os.Getenv(name)) switch p { @@ -395,12 +452,17 @@ func BooleanOption(name string, defaultVal bool) (bool, error) { return false, fmt.Errorf("env variable %q must have a boolean value (\"true\" or \"false\"), got %q", name, p) } -// checks and validates env option +// TimeFormatOptionFromEnv checks and validates the time format option func TimeFormatOptionFromEnv(env, defaultVal string) (string, error) { val, set := os.LookupEnv(env) if !set { return defaultVal, nil } + + return getTimeFormatOption(val) +} + +func getTimeFormatOption(val string) (string, error) { switch val { case "layout": return time.Layout, nil @@ -432,6 +494,38 @@ func TimeFormatOptionFromEnv(env, defaultVal string) (string, error) { return "", fmt.Errorf("env variable %q must have a timeformat value (\"layout|ansic|unixdate|rubydate|rfc822|rfc822z|rfc850|rfc1123|rfc1123z|rfc3339|rfc3339nano|kitchen\"), got %q", EnvTimeFormatLogs, val) } +// ValueToTimeFormatOption converts the time format option to the env value +func ValueToTimeFormatOption(format string) string { + switch format { + case time.Layout: + return "layout" + case time.ANSIC: + return "ansic" + case time.UnixDate: + return "unixdate" + case time.RubyDate: + return "rubydate" + case time.RFC822: + return "rfc822" + case time.RFC822Z: + return "rfc822z" + case time.RFC850: + return "rfc850" + case time.RFC1123: + return "rfc1123" + case time.RFC1123Z: + return "rfc1123z" + case time.RFC3339: + return "rfc3339" + case time.RFC3339Nano: + return "rfc3339nano" + case time.Kitchen: + return "kitchen" + default: + return "" + } +} + // DetailString returns a multi-line string with details about this config. func (cfg Config) DetailString() string { configEntries := []struct{ name, value string }{ @@ -445,11 +539,11 @@ func (cfg Config) DetailString() string { {EnvInterval, cfg.PollInterval.String()}, {EnvSkipBackup, fmt.Sprintf("%t", cfg.UnsafeSkipBackup)}, {EnvDataBackupPath, cfg.DataBackupPath}, - {EnvPreupgradeMaxRetries, fmt.Sprintf("%d", cfg.PreupgradeMaxRetries)}, + {EnvPreupgradeMaxRetries, fmt.Sprintf("%d", cfg.PreUpgradeMaxRetries)}, {EnvDisableLogs, fmt.Sprintf("%t", cfg.DisableLogs)}, {EnvColorLogs, fmt.Sprintf("%t", cfg.ColorLogs)}, {EnvTimeFormatLogs, cfg.TimeFormatLogs}, - {EnvCustomPreupgrade, cfg.CustomPreupgrade}, + {EnvCustomPreupgrade, cfg.CustomPreUpgrade}, {EnvDisableRecase, fmt.Sprintf("%t", cfg.DisableRecase)}, } @@ -479,3 +573,48 @@ func (cfg Config) DetailString() string { } return sb.String() } + +// Export exports the configuration to a file at the given path. +func (cfg Config) Export() (string, error) { + // always use the default path + path := filepath.Clean(cfg.DefaultCfgPath()) + + // check if config file already exists ask user if they want to overwrite it + if _, err := os.Stat(path); err == nil { + // ask user if they want to overwrite the file + if !askForConfirmation(fmt.Sprintf("file %s already exists, do you want to overwrite it?", path)) { + cfg.Logger(os.Stdout).Info("file already exists, not overriding") + return path, nil + } + } + + // create the file + file, err := os.Create(filepath.Clean(path)) + if err != nil { + return "", fmt.Errorf("failed to create configuration file: %w", err) + } + + // convert the time value to its format option + cfg.TimeFormatLogs = ValueToTimeFormatOption(cfg.TimeFormatLogs) + + defer file.Close() + + // write the configuration to the file + err = toml.NewEncoder(file).Encode(cfg) + if err != nil { + return "", fmt.Errorf("failed to encode configuration: %w", err) + } + + return path, nil +} + +func askForConfirmation(str string) bool { + var response string + fmt.Printf("%s [y/n]: ", str) + _, err := fmt.Scanln(&response) + if err != nil { + return false + } + + return strings.ToLower(response) == "y" +} diff --git a/tools/cosmovisor/args_test.go b/tools/cosmovisor/args_test.go index e161d229e7db..b379720763f0 100644 --- a/tools/cosmovisor/args_test.go +++ b/tools/cosmovisor/args_test.go @@ -7,6 +7,7 @@ import ( "testing" "time" + "github.com/pelletier/go-toml/v2" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -412,7 +413,7 @@ func (s *argsTestSuite) TestDetailString() { PollInterval: pollInterval, UnsafeSkipBackup: unsafeSkipBackup, DataBackupPath: dataBackupPath, - PreupgradeMaxRetries: preupgradeMaxRetries, + PreUpgradeMaxRetries: preupgradeMaxRetries, } expectedPieces := []string{ @@ -444,6 +445,41 @@ func (s *argsTestSuite) TestDetailString() { } } +var newConfig = func( + home, name string, + downloadBin bool, + downloadMustHaveChecksum bool, + restartUpgrade bool, + restartDelay int, + skipBackup bool, + dataBackupPath string, + interval, preupgradeMaxRetries int, + disableLogs, colorLogs bool, + timeFormatLogs string, + customPreUpgrade string, + disableRecase bool, + shutdownGrace int, +) *Config { + return &Config{ + Home: home, + Name: name, + AllowDownloadBinaries: downloadBin, + DownloadMustHaveChecksum: downloadMustHaveChecksum, + RestartAfterUpgrade: restartUpgrade, + RestartDelay: time.Millisecond * time.Duration(restartDelay), + PollInterval: time.Millisecond * time.Duration(interval), + UnsafeSkipBackup: skipBackup, + DataBackupPath: dataBackupPath, + PreUpgradeMaxRetries: preupgradeMaxRetries, + DisableLogs: disableLogs, + ColorLogs: colorLogs, + TimeFormatLogs: timeFormatLogs, + CustomPreUpgrade: customPreUpgrade, + DisableRecase: disableRecase, + ShutdownGrace: time.Duration(shutdownGrace), + } +} + func (s *argsTestSuite) TestGetConfigFromEnv() { initialEnv := s.clearEnv() defer s.setEnv(nil, initialEnv) @@ -452,41 +488,6 @@ func (s *argsTestSuite) TestGetConfigFromEnv() { absPath, perr := filepath.Abs(relPath) s.Require().NoError(perr) - newConfig := func( - home, name string, - downloadBin bool, - downloadMustHaveChecksum bool, - restartUpgrade bool, - restartDelay int, - skipBackup bool, - dataBackupPath string, - interval, preupgradeMaxRetries int, - disableLogs, colorLogs bool, - timeFormatLogs string, - customPreUpgrade string, - disableRecase bool, - shutdownGrace int, - ) *Config { - return &Config{ - Home: home, - Name: name, - AllowDownloadBinaries: downloadBin, - DownloadMustHaveChecksum: downloadMustHaveChecksum, - RestartAfterUpgrade: restartUpgrade, - RestartDelay: time.Millisecond * time.Duration(restartDelay), - PollInterval: time.Millisecond * time.Duration(interval), - UnsafeSkipBackup: skipBackup, - DataBackupPath: dataBackupPath, - PreupgradeMaxRetries: preupgradeMaxRetries, - DisableLogs: disableLogs, - ColorLogs: colorLogs, - TimeFormatLogs: timeFormatLogs, - CustomPreupgrade: customPreUpgrade, - DisableRecase: disableRecase, - ShutdownGrace: time.Duration(shutdownGrace), - } - } - tests := []struct { name string envVals cosmovisorEnv @@ -783,6 +784,95 @@ func (s *argsTestSuite) TestGetConfigFromEnv() { } } +func (s *argsTestSuite) setUpDir() string { + s.T().Helper() + + home := s.T().TempDir() + err := os.MkdirAll(filepath.Join(home, rootName), 0o755) + s.Require().NoError(err) + return home +} + +func (s *argsTestSuite) setupConfig(home string) string { + s.T().Helper() + + cfg := newConfig(home, "test", true, true, true, 406, false, home, 8, 0, false, true, "kitchen", "", true, 10000000000) + path := filepath.Join(home, rootName, "config.toml") + f, err := os.Create(path) + s.Require().NoError(err) + + enc := toml.NewEncoder(f) + s.Require().NoError(enc.Encode(&cfg)) + + err = f.Close() + s.Require().NoError(err) + + return path +} + +func (s *argsTestSuite) TestConfigFromFile() { + home := s.setUpDir() + // create a config file + cfgFilePath := s.setupConfig(home) + + testCases := []struct { + name string + config *Config + expectedCfg func() *Config + filePath string + expectedError string + malleate func() + }{ + { + name: "valid config", + expectedCfg: func() *Config { + return newConfig(home, "test", true, true, true, 406, false, home, 8, 0, false, true, time.Kitchen, "", true, 10000000000) + }, + filePath: cfgFilePath, + expectedError: "", + malleate: func() {}, + }, + { + name: "env variable will override config file fields", + filePath: cfgFilePath, + expectedError: "", + malleate: func() { + // set env variable different from the config file + os.Setenv(EnvName, "env-name") + }, + expectedCfg: func() *Config { + return newConfig(home, "env-name", true, true, true, 406, false, home, 8, 0, false, true, time.Kitchen, "", true, 10000000000) + }, + }, + { + name: "empty config file path will load config from ENV variables", + expectedCfg: func() *Config { + return newConfig(home, "test", true, true, true, 406, false, home, 8, 0, false, true, time.Kitchen, "", true, 10000000000) + }, + filePath: "", + expectedError: "", + malleate: func() { + s.setEnv(s.T(), &cosmovisorEnv{home, "test", "true", "true", "true", "406ms", "false", home, "8ms", "0", "false", "true", "kitchen", "", "true", "10s"}) + }, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + tc.malleate() + actualCfg, err := GetConfigFromFile(tc.filePath) + if tc.expectedError != "" { + s.Require().NoError(err) + s.Require().Contains(err.Error(), tc.expectedError) + return + } + + s.Require().NoError(err) + s.Require().EqualValues(tc.expectedCfg(), actualCfg) + }) + } +} + var sink interface{} func BenchmarkDetailString(b *testing.B) { @@ -791,7 +881,7 @@ func BenchmarkDetailString(b *testing.B) { AllowDownloadBinaries: true, UnsafeSkipBackup: true, PollInterval: 450 * time.Second, - PreupgradeMaxRetries: 1e7, + PreUpgradeMaxRetries: 1e7, } b.ReportAllocs() diff --git a/tools/cosmovisor/cmd/cosmovisor/add_upgrade.go b/tools/cosmovisor/cmd/cosmovisor/add_upgrade.go index 4e52d85728c5..b188ab05fdc4 100644 --- a/tools/cosmovisor/cmd/cosmovisor/add_upgrade.go +++ b/tools/cosmovisor/cmd/cosmovisor/add_upgrade.go @@ -30,7 +30,12 @@ func NewAddUpgradeCmd() *cobra.Command { // AddUpgrade adds upgrade info to manifest func AddUpgrade(cmd *cobra.Command, args []string) error { - cfg, err := cosmovisor.GetConfigFromEnv() + configPath, err := cmd.Flags().GetString(cosmovisor.FlagCosmovisorConfig) + if err != nil { + return fmt.Errorf("failed to get config flag: %w", err) + } + + cfg, err := cosmovisor.GetConfigFromFile(configPath) if err != nil { return err } diff --git a/tools/cosmovisor/cmd/cosmovisor/config.go b/tools/cosmovisor/cmd/cosmovisor/config.go index 927e005ebf6a..d651e5fb3839 100644 --- a/tools/cosmovisor/cmd/cosmovisor/config.go +++ b/tools/cosmovisor/cmd/cosmovisor/config.go @@ -7,11 +7,13 @@ import ( ) var configCmd = &cobra.Command{ - Use: "config", - Short: "Display cosmovisor config (prints environment variables used by cosmovisor).", + Use: "config", + Short: "Display cosmovisor config.", + Long: `Display cosmovisor config. If a config file is provided, it will display the config from the file, +otherwise it will display the config from the environment variables.`, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - cfg, err := cosmovisor.GetConfigFromEnv() + cfg, err := cosmovisor.GetConfigFromFile(cmd.Flag(cosmovisor.FlagCosmovisorConfig).Value.String()) if err != nil { return err } diff --git a/tools/cosmovisor/cmd/cosmovisor/init.go b/tools/cosmovisor/cmd/cosmovisor/init.go index 9b08529e647e..66d99b68032e 100644 --- a/tools/cosmovisor/cmd/cosmovisor/init.go +++ b/tools/cosmovisor/cmd/cosmovisor/init.go @@ -15,14 +15,20 @@ import ( "cosmossdk.io/x/upgrade/plan" ) -var initCmd = &cobra.Command{ - Use: "init ", - Short: "Initialize a cosmovisor daemon home directory.", - Args: cobra.ExactArgs(1), - SilenceUsage: true, - RunE: func(cmd *cobra.Command, args []string) error { - return InitializeCosmovisor(nil, args) - }, +func NewIntCmd() *cobra.Command { + initCmd := &cobra.Command{ + Use: "init ", + Short: "Initialize a cosmovisor daemon home directory.", + Long: `Initialize a cosmovisor daemon home directory with the provided executable. +Configuration file is initialized at the default path (<-home->/cosmovisor/config.toml).`, + Args: cobra.ExactArgs(1), + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return InitializeCosmovisor(nil, args) + }, + } + + return initCmd } // InitializeCosmovisor initializes the cosmovisor directories, current link, and initial executable. @@ -88,12 +94,20 @@ func InitializeCosmovisor(logger log.Logger, args []string) error { } logger.Info(fmt.Sprintf("the current symlink points to: %q", cur)) + filePath, err := cfg.Export() + if err != nil { + return fmt.Errorf("failed to export configuration: %w", err) + } + + logger.Info(fmt.Sprintf("config file present at: %s", filePath)) + return nil } // getConfigForInitCmd gets just the configuration elements needed to initialize cosmovisor. func getConfigForInitCmd() (*cosmovisor.Config, error) { var errs []error + // Note: Not using GetConfigFromEnv here because that checks that the directories already exist. // We also don't care about the rest of the configuration stuff in here. cfg := &cosmovisor.Config{ @@ -105,19 +119,27 @@ func getConfigForInitCmd() (*cosmovisor.Config, error) { if cfg.ColorLogs, err = cosmovisor.BooleanOption(cosmovisor.EnvColorLogs, true); err != nil { errs = append(errs, err) } + if cfg.TimeFormatLogs, err = cosmovisor.TimeFormatOptionFromEnv(cosmovisor.EnvTimeFormatLogs, time.Kitchen); err != nil { errs = append(errs, err) } + // if backup is not set, use the home directory + if cfg.DataBackupPath == "" { + cfg.DataBackupPath = cfg.Home + } + if len(cfg.Name) == 0 { errs = append(errs, fmt.Errorf("%s is not set", cosmovisor.EnvName)) } + switch { case len(cfg.Home) == 0: errs = append(errs, fmt.Errorf("%s is not set", cosmovisor.EnvHome)) case !filepath.IsAbs(cfg.Home): errs = append(errs, fmt.Errorf("%s must be an absolute path", cosmovisor.EnvHome)) } + if len(errs) > 0 { return cfg, errors.Join(errs...) } diff --git a/tools/cosmovisor/cmd/cosmovisor/init_test.go b/tools/cosmovisor/cmd/cosmovisor/init_test.go index c872675e280d..95d971ab08ed 100644 --- a/tools/cosmovisor/cmd/cosmovisor/init_test.go +++ b/tools/cosmovisor/cmd/cosmovisor/init_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/pelletier/go-toml/v2" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" @@ -18,7 +20,10 @@ import ( ) const ( - notset = " is not set" + notset = " is not set" + cosmovisorDirName = "cosmovisor" + + cfgFileWithExt = "config.toml" ) type InitTestSuite struct { @@ -79,6 +84,7 @@ func (s *InitTestSuite) clearEnv() *cosmovisorInitEnv { for envVar := range rv.ToMap() { rv.Set(envVar, os.Getenv(envVar)) s.Require().NoError(os.Unsetenv(envVar)) + viper.Reset() } return &rv } @@ -111,6 +117,26 @@ func (s *InitTestSuite) setEnv(t *testing.T, env *cosmovisorInitEnv) { //nolint: } } +// readStdInpFromFile reads the provided data as if it were a standard input. +func (s *InitTestSuite) readStdInpFromFile(data []byte) { + // Create a temporary file and write the test input into it + tmpfile, err := os.CreateTemp("", "test") + if err != nil { + s.T().Fatal(err) + } + + // write the test input into the temporary file + if _, err := tmpfile.Write(data); err != nil { + s.T().Fatal(err) + } + + if _, err := tmpfile.Seek(0, 0); err != nil { + s.T().Fatal(err) + } + + os.Stdin = tmpfile +} + var ( _ io.Reader = BufferedPipe{} _ io.Writer = BufferedPipe{} @@ -247,7 +273,7 @@ func (p *BufferedPipe) panicIfStarted(msg string) { func (s *InitTestSuite) NewCapturingLogger() (*BufferedPipe, log.Logger) { bufferedStdOut, err := StartNewBufferedPipe("stdout", os.Stdout) s.Require().NoError(err, "creating stdout buffered pipe") - logger := log.NewLogger(bufferedStdOut, log.ColorOption(false), log.TimeFormatOption(time.RFC3339Nano)).With(log.ModuleKey, "cosmovisor") + logger := log.NewLogger(bufferedStdOut, log.ColorOption(false), log.TimeFormatOption(time.RFC3339Nano)).With(log.ModuleKey, cosmovisorDirName) return &bufferedStdOut, logger } @@ -360,7 +386,7 @@ func (s *InitTestSuite) TestInitializeCosmovisorInvalidExisting() { Home: filepath.Join(testDir, "home"), Name: "pear", } - genDir := filepath.Join(env.Home, "cosmovisor", "genesis") + genDir := filepath.Join(env.Home, cosmovisorDirName, "genesis") genBin := filepath.Join(genDir, "bin") require.NoError(t, os.MkdirAll(genDir, 0o755), "creating genesis directory") require.NoError(t, copyFile(hwExe, genBin), "copying exe to genesis/bin") @@ -380,7 +406,7 @@ func (s *InitTestSuite) TestInitializeCosmovisorInvalidExisting() { } // Create the genesis bin executable path fully as a directory (instead of a file). // That should get through all the other stuff, but error when EnsureBinary is called. - genBinExe := filepath.Join(env.Home, "cosmovisor", "genesis", "bin", env.Name) + genBinExe := filepath.Join(env.Home, cosmovisorDirName, "genesis", "bin", env.Name) require.NoError(t, os.MkdirAll(genBinExe, 0o755)) expErr := fmt.Sprintf("%s is not a regular file", env.Name) // Check the log messages just to make sure it's erroring where expecting. @@ -416,7 +442,7 @@ func (s *InitTestSuite) TestInitializeCosmovisorInvalidExisting() { Home: filepath.Join(testDir, "home"), Name: "orange", } - rootDir := filepath.Join(env.Home, "cosmovisor") + rootDir := filepath.Join(env.Home, cosmovisorDirName) require.NoError(t, os.MkdirAll(rootDir, 0o755)) curLn := filepath.Join(rootDir, "current") genDir := filepath.Join(rootDir, "genesis") @@ -465,8 +491,8 @@ func (s *InitTestSuite) TestInitializeCosmovisorValid() { Home: filepath.Join(testDir, "home"), Name: "blank", } - curLn := filepath.Join(env.Home, "cosmovisor", "current") - genBinDir := filepath.Join(env.Home, "cosmovisor", "genesis", "bin") + curLn := filepath.Join(env.Home, cosmovisorDirName, "current") + genBinDir := filepath.Join(env.Home, cosmovisorDirName, "genesis", "bin") genBinExe := filepath.Join(genBinDir, env.Name) expInLog := []string{ "checking on the genesis/bin directory", @@ -476,6 +502,7 @@ func (s *InitTestSuite) TestInitializeCosmovisorValid() { fmt.Sprintf("making sure %q is executable", genBinExe), "checking on the current symlink and creating it if needed", fmt.Sprintf("the current symlink points to: %q", genBinExe), + fmt.Sprintf("config file present at: %s", filepath.Join(env.Home, cosmovisorDirName, cfgFileWithExt)), } s.setEnv(s.T(), env) @@ -508,7 +535,7 @@ func (s *InitTestSuite) TestInitializeCosmovisorValid() { Home: filepath.Join(testDir, "home"), Name: "nocur", } - rootDir := filepath.Join(env.Home, "cosmovisor") + rootDir := filepath.Join(env.Home, cosmovisorDirName) genBinDir := filepath.Join(rootDir, "genesis", "bin") genBinDirExe := filepath.Join(genBinDir, env.Name) require.NoError(t, os.MkdirAll(genBinDir, 0o755), "making genesis bin dir") @@ -528,6 +555,7 @@ func (s *InitTestSuite) TestInitializeCosmovisorValid() { fmt.Sprintf("the %q file already exists", genBinDirExe), fmt.Sprintf("making sure %q is executable", genBinDirExe), fmt.Sprintf("the current symlink points to: %q", genBinDirExe), + fmt.Sprintf("config file present at: %s", filepath.Join(env.Home, cosmovisorDirName, cfgFileWithExt)), } s.setEnv(t, env) @@ -548,7 +576,7 @@ func (s *InitTestSuite) TestInitializeCosmovisorValid() { Home: filepath.Join(testDir, "home"), Name: "emptygen", } - rootDir := filepath.Join(env.Home, "cosmovisor") + rootDir := filepath.Join(env.Home, cosmovisorDirName) genBinDir := filepath.Join(rootDir, "genesis", "bin") genBinExe := filepath.Join(genBinDir, env.Name) require.NoError(t, os.MkdirAll(genBinDir, 0o755), "making genesis bin dir") @@ -560,6 +588,7 @@ func (s *InitTestSuite) TestInitializeCosmovisorValid() { fmt.Sprintf("copying executable into place: %q", genBinExe), fmt.Sprintf("making sure %q is executable", genBinExe), fmt.Sprintf("the current symlink points to: %q", genBinExe), + fmt.Sprintf("config file present at: %s", filepath.Join(env.Home, cosmovisorDirName, cfgFileWithExt)), } s.setEnv(t, env) @@ -573,4 +602,111 @@ func (s *InitTestSuite) TestInitializeCosmovisorValid() { assert.Contains(t, bufferStr, exp) } }) + + s.T().Run("ask to override (y/n) the existing config file", func(t *testing.T) { + }) + + s.T().Run("init command exports configs to default path", func(t *testing.T) { + testDir := s.T().TempDir() + env := &cosmovisorInitEnv{ + Home: filepath.Join(testDir, "home"), + Name: "emptygen", + } + + s.setEnv(t, env) + buffer, logger := s.NewCapturingLogger() + logger.Info(fmt.Sprintf("Calling InitializeCosmovisor: %s", t.Name())) + err := InitializeCosmovisor(logger, []string{hwExe}) + require.NoError(t, err, "calling InitializeCosmovisor") + bufferBz := buffer.Collect() + bufferStr := string(bufferBz) + assert.Contains(t, bufferStr, fmt.Sprintf("config file present at: %s", filepath.Join(env.Home, cosmovisorDirName, cfgFileWithExt))) + }) +} + +func (s *InitTestSuite) TestInitializeCosmovisorWithOverrideCfg() { + initEnv := s.clearEnv() + defer s.setEnv(nil, initEnv) + + tmpExe := s.CreateHelloWorld(0o755) + testDir := s.T().TempDir() + homePath := filepath.Join(testDir, "backup") + testCases := []struct { + name string + input string + cfg *cosmovisor.Config + override bool + }{ + { + name: "yes override", + input: "y\n", + cfg: &cosmovisor.Config{ + Home: homePath, + Name: "old_test", + DataBackupPath: homePath, + }, + override: true, + }, + { + name: "no override", + input: "n\n", + cfg: &cosmovisor.Config{ + Home: homePath, + Name: "old_test", + DataBackupPath: homePath, + }, + override: false, + }, + } + + for _, tc := range testCases { + s.T().Run(tc.name, func(t *testing.T) { + // create a root cosmovisor directory + require.NoError(t, os.MkdirAll(tc.cfg.Root(), 0o755), "making root dir") + + // create a config file in the default location + file, err := os.Create(tc.cfg.DefaultCfgPath()) + require.NoError(t, err) + + // write the config to the file + err = toml.NewEncoder(file).Encode(tc.cfg) + require.NoError(t, err) + + err = file.Close() + require.NoError(t, err) + + s.readStdInpFromFile([]byte(tc.input)) + + _, logger := s.NewCapturingLogger() + logger.Info(fmt.Sprintf("Calling InitializeCosmovisor: %s", t.Name())) + + // override the daemon name in environment file + // if override is true (y), then the name should be updated in the config file + // otherwise (n), the name should not be updated in the config file + s.setEnv(t, &cosmovisorInitEnv{ + Home: tc.cfg.Home, + Name: "update_name", + }) + + err = InitializeCosmovisor(logger, []string{tmpExe}) + require.NoError(t, err, "calling InitializeCosmovisor") + + cfg := &cosmovisor.Config{} + // read the config file + cfgFile, err := os.Open(tc.cfg.DefaultCfgPath()) + require.NoError(t, err) + defer cfgFile.Close() + + err = toml.NewDecoder(cfgFile).Decode(cfg) + require.NoError(t, err) + if tc.override { + // check if the name is updated + // basically, override the existing config file + assert.Equal(t, "update_name", cfg.Name) + } else { + // daemon name should not be updated + assert.Equal(t, tc.cfg.Name, cfg.Name) + } + }) + } } diff --git a/tools/cosmovisor/cmd/cosmovisor/root.go b/tools/cosmovisor/cmd/cosmovisor/root.go index 4d87d9867c2d..d9f6094d593c 100644 --- a/tools/cosmovisor/cmd/cosmovisor/root.go +++ b/tools/cosmovisor/cmd/cosmovisor/root.go @@ -2,6 +2,8 @@ package main import ( "github.com/spf13/cobra" + + "cosmossdk.io/tools/cosmovisor" ) func NewRootCmd() *cobra.Command { @@ -12,12 +14,13 @@ func NewRootCmd() *cobra.Command { } rootCmd.AddCommand( - initCmd, + NewIntCmd(), runCmd, configCmd, NewVersionCmd(), NewAddUpgradeCmd(), ) + rootCmd.PersistentFlags().StringP(cosmovisor.FlagCosmovisorConfig, "c", "", "path to cosmovisor config file") return rootCmd } diff --git a/tools/cosmovisor/cmd/cosmovisor/run.go b/tools/cosmovisor/cmd/cosmovisor/run.go index 24aa9b6f8991..6aa938c7d780 100644 --- a/tools/cosmovisor/cmd/cosmovisor/run.go +++ b/tools/cosmovisor/cmd/cosmovisor/run.go @@ -1,24 +1,35 @@ package main import ( + "fmt" + "strings" + "github.com/spf13/cobra" "cosmossdk.io/tools/cosmovisor" ) var runCmd = &cobra.Command{ - Use: "run", - Short: "Run an APP command.", + Use: "run", + Short: "Run an APP command.", + Long: `Run an APP command. This command is intended to be used by the cosmovisor binary. +Provide cosmovisor config file path in command args or set env variables to load configuration. +`, SilenceUsage: true, DisableFlagParsing: true, RunE: func(_ *cobra.Command, args []string) error { - return run(args) + cfgPath, args, err := parseCosmovisorConfig(args) + if err != nil { + return fmt.Errorf("failed to parse cosmovisor config: %w", err) + } + + return run(cfgPath, args) }, } // run runs the configured program with the given args and monitors it for upgrades. -func run(args []string, options ...RunOption) error { - cfg, err := cosmovisor.GetConfigFromEnv() +func run(cfgPath string, args []string, options ...RunOption) error { + cfg, err := cosmovisor.GetConfigFromFile(cfgPath) if err != nil { return err } @@ -47,3 +58,24 @@ func run(args []string, options ...RunOption) error { return err } + +func parseCosmovisorConfig(args []string) (string, []string, error) { + var configFilePath string + for i, arg := range args { + // Check if the argument is the config flag + if strings.EqualFold(arg, fmt.Sprintf("--%s", cosmovisor.FlagCosmovisorConfig)) || + strings.EqualFold(arg, fmt.Sprintf("-%s", cosmovisor.FlagCosmovisorConfig)) { + // Check if there is an argument after the flag which should be the config file path + if i+1 >= len(args) { + return "", nil, fmt.Errorf("--%s requires an argument", cosmovisor.FlagCosmovisorConfig) + } + + configFilePath = args[i+1] + // Remove the flag and its value from the arguments + args = append(args[:i], args[i+2:]...) + break + } + } + + return configFilePath, args, nil +} diff --git a/tools/cosmovisor/cmd/cosmovisor/version.go b/tools/cosmovisor/cmd/cosmovisor/version.go index fa9778ea54d8..a51b376355af 100644 --- a/tools/cosmovisor/cmd/cosmovisor/version.go +++ b/tools/cosmovisor/cmd/cosmovisor/version.go @@ -47,7 +47,7 @@ func printVersion(cmd *cobra.Command, args []string, noAppVersion bool) error { return nil } - if err := run(append([]string{"version"}, args...)); err != nil { + if err := run("", append([]string{"version"}, args...)); err != nil { return fmt.Errorf("failed to run version command: %w", err) } @@ -62,6 +62,7 @@ func printVersionJSON(cmd *cobra.Command, args []string, noAppVersion bool) erro buf := new(strings.Builder) if err := run( + "", []string{"version", "--long", "--output", "json"}, StdOutRunOption(buf), ); err != nil { diff --git a/tools/cosmovisor/flags.go b/tools/cosmovisor/flags.go index 73c17b9247dd..7c0db36bee66 100644 --- a/tools/cosmovisor/flags.go +++ b/tools/cosmovisor/flags.go @@ -6,4 +6,5 @@ const ( FlagCosmovisorOnly = "cosmovisor-only" FlagForce = "force" FlagUpgradeHeight = "upgrade-height" + FlagCosmovisorConfig = "cosmovisor-config" ) diff --git a/tools/cosmovisor/process.go b/tools/cosmovisor/process.go index 47d70e301f93..8eb4eae6a073 100644 --- a/tools/cosmovisor/process.go +++ b/tools/cosmovisor/process.go @@ -201,7 +201,7 @@ func (l Launcher) doBackup() error { // doCustomPreUpgrade executes the custom preupgrade script if provided. func (l Launcher) doCustomPreUpgrade() error { - if l.cfg.CustomPreupgrade == "" { + if l.cfg.CustomPreUpgrade == "" { return nil } @@ -221,7 +221,7 @@ func (l Launcher) doCustomPreUpgrade() error { } // check if preupgradeFile is executable file - preupgradeFile := filepath.Join(l.cfg.Home, "cosmovisor", l.cfg.CustomPreupgrade) + preupgradeFile := filepath.Join(l.cfg.Home, "cosmovisor", l.cfg.CustomPreUpgrade) l.logger.Info("looking for COSMOVISOR_CUSTOM_PREUPGRADE file", "file", preupgradeFile) info, err := os.Stat(preupgradeFile) if err != nil { @@ -264,8 +264,8 @@ func (l Launcher) doCustomPreUpgrade() error { func (l *Launcher) doPreUpgrade() error { counter := 0 for { - if counter > l.cfg.PreupgradeMaxRetries { - return fmt.Errorf("pre-upgrade command failed. reached max attempt of retries - %d", l.cfg.PreupgradeMaxRetries) + if counter > l.cfg.PreUpgradeMaxRetries { + return fmt.Errorf("pre-upgrade command failed. reached max attempt of retries - %d", l.cfg.PreUpgradeMaxRetries) } if err := l.executePreUpgradeCmd(); err != nil { diff --git a/tools/cosmovisor/process_test.go b/tools/cosmovisor/process_test.go index 3169ba3e3082..aa587042668c 100644 --- a/tools/cosmovisor/process_test.go +++ b/tools/cosmovisor/process_test.go @@ -272,7 +272,7 @@ func (s *processTestSuite) TestLaunchProcessWithDownloadsAndMissingPreupgrade() AllowDownloadBinaries: true, PollInterval: 100, UnsafeSkipBackup: true, - CustomPreupgrade: "missing.sh", + CustomPreUpgrade: "missing.sh", } logger := log.NewTestLogger(s.T()).With(log.ModuleKey, "cosmovisor") upgradeFilename := cfg.UpgradeInfoFilePath() @@ -308,7 +308,7 @@ func (s *processTestSuite) TestLaunchProcessWithDownloadsAndPreupgrade() { AllowDownloadBinaries: true, PollInterval: 100, UnsafeSkipBackup: true, - CustomPreupgrade: "preupgrade.sh", + CustomPreUpgrade: "preupgrade.sh", } buf := newBuffer() // inspect output using buf.String() logger := log.NewLogger(buf).With(log.ModuleKey, "cosmovisor")