diff --git a/cosmovisor/CHANGELOG.md b/cosmovisor/CHANGELOG.md index 50b328fc60..52c4747040 100644 --- a/cosmovisor/CHANGELOG.md +++ b/cosmovisor/CHANGELOG.md @@ -43,7 +43,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ + [\#10128](https://github.com/cosmos/cosmos-sdk/pull/10128) Change default value of `DAEMON_RESTART_AFTER_UPGRADE` to `true`. + [\#9999](https://github.com/cosmos/cosmos-sdk/pull/10103) Added `version` command that returns the cosmovisor version and the application version. + [\#9973](https://github.com/cosmos/cosmos-sdk/pull/10056) Added support for pre-upgrade command in Cosmovisor to be called before the binary is upgraded. Added new environmental variable `DAEMON_PREUPGRADE_MAX_RETRIES` that holds the maximum number of times to reattempt pre-upgrade before failing. - ++ [\#10126](https://github.com/cosmos/cosmos-sdk/pull/10229) Added `help`. ### Improvements diff --git a/cosmovisor/args.go b/cosmovisor/args.go index 20608332ec..7b9343fea5 100644 --- a/cosmovisor/args.go +++ b/cosmovisor/args.go @@ -11,17 +11,19 @@ import ( "strconv" "strings" "time" + + cverrors "github.com/cosmos/cosmos-sdk/cosmovisor/errors" ) // environment variable names const ( - envHome = "DAEMON_HOME" - envName = "DAEMON_NAME" - envDownloadBin = "DAEMON_ALLOW_DOWNLOAD_BINARIES" - envRestartUpgrade = "DAEMON_RESTART_AFTER_UPGRADE" - envSkipBackup = "UNSAFE_SKIP_BACKUP" - envInterval = "DAEMON_POLL_INTERVAL" - envPreupgradeMaxRetries = "DAEMON_PREUPGRADE_MAX_RETRIES" + EnvHome = "DAEMON_HOME" + EnvName = "DAEMON_NAME" + EnvDownloadBin = "DAEMON_ALLOW_DOWNLOAD_BINARIES" + EnvRestartUpgrade = "DAEMON_RESTART_AFTER_UPGRADE" + EnvSkipBackup = "UNSAFE_SKIP_BACKUP" + EnvInterval = "DAEMON_POLL_INTERVAL" + EnvPreupgradeMaxRetries = "DAEMON_PREUPGRADE_MAX_RETRIES" ) const ( @@ -67,10 +69,15 @@ func (cfg *Config) UpgradeBin(upgradeName string) string { // UpgradeDir is the directory named upgrade func (cfg *Config) UpgradeDir(upgradeName string) string { safeName := url.PathEscape(upgradeName) - return filepath.Join(cfg.Home, rootName, upgradesDir, safeName) + return filepath.Join(cfg.BaseUpgradeDir(), safeName) } -// UpgradeInfoFile is the expected upgrade-info filename created by `x/upgrade/keeper`. +// BaseUpgradeDir is the directory containing the named upgrade directories. +func (cfg *Config) BaseUpgradeDir() string { + return filepath.Join(cfg.Root(), upgradesDir) +} + +// UpgradeInfoFilePath is the expected upgrade-info filename created by `x/upgrade/keeper`. func (cfg *Config) UpgradeInfoFilePath() string { return filepath.Join(cfg.Home, "data", defaultFilename) } @@ -118,72 +125,74 @@ func (cfg *Config) CurrentBin() (string, error) { // GetConfigFromEnv will read the environmental variables into a config // and then validate it is reasonable func GetConfigFromEnv() (*Config, error) { + var errs []error cfg := &Config{ - Home: os.Getenv(envHome), - Name: os.Getenv(envName), + Home: os.Getenv(EnvHome), + Name: os.Getenv(EnvName), } var err error - if cfg.AllowDownloadBinaries, err = booleanOption(envDownloadBin, false); err != nil { - return nil, err + if cfg.AllowDownloadBinaries, err = booleanOption(EnvDownloadBin, false); err != nil { + errs = append(errs, err) } - if cfg.RestartAfterUpgrade, err = booleanOption(envRestartUpgrade, true); err != nil { - return nil, err + if cfg.RestartAfterUpgrade, err = booleanOption(EnvRestartUpgrade, true); err != nil { + errs = append(errs, err) } - if cfg.UnsafeSkipBackup, err = booleanOption(envSkipBackup, false); err != nil { - return nil, err + if cfg.UnsafeSkipBackup, err = booleanOption(EnvSkipBackup, false); err != nil { + errs = append(errs, err) } - interval := os.Getenv(envInterval) + interval := os.Getenv(EnvInterval) if interval != "" { - i, err := strconv.ParseUint(interval, 10, 32) - if err != nil { - return nil, err + switch i, e := strconv.ParseUint(interval, 10, 32); { + case e != nil: + errs = append(errs, fmt.Errorf("invalid %s: %w", EnvInterval, err)) + case i == 0: + errs = append(errs, fmt.Errorf("invalid %s: cannot be 0", EnvInterval)) + default: + cfg.PollInterval = time.Millisecond * time.Duration(i) } - cfg.PollInterval = time.Millisecond * time.Duration(i) } else { cfg.PollInterval = 300 * time.Millisecond } - if err := cfg.validate(); err != nil { - return nil, err - } - - envPreupgradeMaxRetriesVal := os.Getenv(envPreupgradeMaxRetries) + envPreupgradeMaxRetriesVal := os.Getenv(EnvPreupgradeMaxRetries) if cfg.PreupgradeMaxRetries, err = strconv.Atoi(envPreupgradeMaxRetriesVal); err != nil && envPreupgradeMaxRetriesVal != "" { - return nil, fmt.Errorf("%s could not be parsed to int: %w", envPreupgradeMaxRetries, err) + errs = append(errs, fmt.Errorf("%s could not be parsed to int: %w", EnvPreupgradeMaxRetries, err)) } + errs = append(errs, cfg.validate()...) + + if len(errs) > 0 { + return nil, cverrors.FlattenErrors(errs...) + } return cfg, nil } // validate returns an error if this config is invalid. // it enforces Home/cosmovisor is a valid directory and exists, // and that Name is set -func (cfg *Config) validate() error { +func (cfg *Config) validate() []error { + var errs []error if cfg.Name == "" { - return errors.New(envName + " is not set") + errs = append(errs, errors.New(EnvName+" is not set")) } - if cfg.Home == "" { - return errors.New(envHome + " is not set") + switch { + case cfg.Home == "": + errs = append(errs, errors.New(EnvHome+" is not set")) + case !filepath.IsAbs(cfg.Home): + errs = append(errs, errors.New(EnvHome+" must be an absolute path")) + default: + switch info, err := os.Stat(cfg.Root()); { + case err != nil: + errs = append(errs, fmt.Errorf("cannot stat home dir: %w", err)) + case !info.IsDir(): + errs = append(errs, fmt.Errorf("%s is not a directory", cfg.Root())) + } } - if !filepath.IsAbs(cfg.Home) { - return errors.New(envHome + " must be an absolute path") - } - - // ensure the root directory exists - info, err := os.Stat(cfg.Root()) - if err != nil { - return fmt.Errorf("cannot stat home dir: %w", err) - } - - if !info.IsDir() { - return fmt.Errorf("%s is not a directory", info.Name()) - } - - return nil + return errs } // SetCurrentUpgrade sets the named upgrade to be the current link, returns error if this binary doesn't exist @@ -265,3 +274,40 @@ 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) } + +// DetailString returns a multi-line string with details about this config. +func (cfg Config) DetailString() string { + configEntries := []struct{ name, value string }{ + {EnvHome, cfg.Home}, + {EnvName, cfg.Name}, + {EnvDownloadBin, fmt.Sprintf("%t", cfg.AllowDownloadBinaries)}, + {EnvRestartUpgrade, fmt.Sprintf("%t", cfg.RestartAfterUpgrade)}, + {EnvInterval, fmt.Sprintf("%s", cfg.PollInterval)}, + {EnvSkipBackup, fmt.Sprintf("%t", cfg.UnsafeSkipBackup)}, + {EnvPreupgradeMaxRetries, fmt.Sprintf("%d", cfg.PreupgradeMaxRetries)}, + } + derivedEntries := []struct{ name, value string }{ + {"Root Dir", cfg.Root()}, + {"Upgrade Dir", cfg.BaseUpgradeDir()}, + {"Genesis Bin", cfg.GenesisBin()}, + {"Monitored File", cfg.UpgradeInfoFilePath()}, + } + + var sb strings.Builder + sb.WriteString("Configurable Values:\n") + for _, kv := range configEntries { + sb.WriteString(fmt.Sprintf(" %s: %s\n", kv.name, kv.value)) + } + sb.WriteString("Derived Values:\n") + dnl := 0 + for _, kv := range derivedEntries { + if len(kv.name) > dnl { + dnl = len(kv.name) + } + } + dFmt := fmt.Sprintf(" %%%ds: %%s\n", dnl) + for _, kv := range derivedEntries { + sb.WriteString(fmt.Sprintf(dFmt, kv.name, kv.value)) + } + return sb.String() +} diff --git a/cosmovisor/args_test.go b/cosmovisor/args_test.go index 4b0bd39d2c..564c7477db 100644 --- a/cosmovisor/args_test.go +++ b/cosmovisor/args_test.go @@ -5,8 +5,13 @@ import ( "os" "path/filepath" "testing" + "time" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" "github.com/stretchr/testify/suite" + + "github.com/cosmos/cosmos-sdk/cosmovisor/errors" ) type argsTestSuite struct { @@ -17,6 +22,94 @@ func TestArgsTestSuite(t *testing.T) { suite.Run(t, new(argsTestSuite)) } +// cosmovisorEnv are the string values of environment variables used to configure Cosmovisor. +type cosmovisorEnv struct { + Home string + Name string + DownloadBin string + RestartUpgrade string + SkipBackup string + Interval string + PreupgradeMaxRetries string +} + +// ToMap creates a map of the cosmovisorEnv where the keys are the env var names. +func (c cosmovisorEnv) ToMap() map[string]string { + return map[string]string{ + EnvHome: c.Home, + EnvName: c.Name, + EnvDownloadBin: c.DownloadBin, + EnvRestartUpgrade: c.RestartUpgrade, + EnvSkipBackup: c.SkipBackup, + EnvInterval: c.Interval, + EnvPreupgradeMaxRetries: c.PreupgradeMaxRetries, + } +} + +// Set sets the field in this cosmovisorEnv corresponding to the provided envVar to the given envVal. +func (c *cosmovisorEnv) Set(envVar, envVal string) { + switch envVar { + case EnvHome: + c.Home = envVal + case EnvName: + c.Name = envVal + case EnvDownloadBin: + c.DownloadBin = envVal + case EnvRestartUpgrade: + c.RestartUpgrade = envVal + case EnvSkipBackup: + c.SkipBackup = envVal + case EnvInterval: + c.Interval = envVal + case EnvPreupgradeMaxRetries: + c.PreupgradeMaxRetries = envVal + default: + panic(fmt.Errorf("Unknown environment variable [%s]. Ccannot set field to [%s]. ", envVar, envVal)) + } +} + +// clearEnv clears environment variables and what they were. +// Designed to be used like this: +// initialEnv := clearEnv() +// defer setEnv(nil, initialEnv) +func (s *argsTestSuite) clearEnv() *cosmovisorEnv { + s.T().Logf("Clearing environment variables.") + rv := cosmovisorEnv{} + for envVar := range rv.ToMap() { + rv.Set(envVar, os.Getenv(envVar)) + s.Require().NoError(os.Unsetenv(envVar)) + } + return &rv +} + +// setEnv sets environment variables to the values provided. +// If t is not nil, and there's a problem, the test will fail immediately. +// If t is nil, problems will just be logged using s.T(). +func (s *argsTestSuite) setEnv(t *testing.T, env *cosmovisorEnv) { + if t == nil { + s.T().Logf("Restoring environment variables.") + } + for envVar, envVal := range env.ToMap() { + var err error + var msg string + if len(envVal) != 0 { + err = os.Setenv(envVar, envVal) + msg = fmt.Sprintf("setting %s to %s", envVar, envVal) + } else { + err = os.Unsetenv(envVar) + msg = fmt.Sprintf("unsetting %s", envVar) + } + switch { + case t != nil: + require.NoError(t, err, msg) + case err != nil: + s.T().Logf("error %s: %v", msg, err) + default: + s.T().Logf("done %s", msg) + } + } +} + func (s *argsTestSuite) TestConfigPaths() { cases := map[string]struct { cfg Config @@ -92,11 +185,11 @@ func (s *argsTestSuite) TestValidate() { } for _, tc := range cases { - err := tc.cfg.validate() + errs := tc.cfg.validate() if tc.valid { - s.Require().NoError(err) + s.Require().Len(errs, 0) } else { - s.Require().Error(err) + s.Require().Greater(len(errs), 0, "number of errors returned") } } } @@ -107,7 +200,7 @@ func (s *argsTestSuite) TestEnsureBin() { s.Require().NoError(err) cfg := Config{Home: absPath, Name: "dummyd"} - s.Require().NoError(cfg.validate()) + s.Require().Len(cfg.validate(), 0, "validation errors") s.Require().NoError(EnsureBinary(cfg.GenesisBin())) @@ -132,17 +225,19 @@ func (s *argsTestSuite) TestEnsureBin() { } func (s *argsTestSuite) TestBooleanOption() { - require := s.Require() + initialEnv := s.clearEnv() + defer s.setEnv(nil, initialEnv) + name := "COSMOVISOR_TEST_VAL" check := func(def, expected, isErr bool, msg string) { v, err := booleanOption(name, def) if isErr { - require.Error(err) + s.Require().Error(err) return } - require.NoError(err) - require.Equal(expected, v, msg) + s.Require().NoError(err) + s.Require().Equal(expected, v, msg) } os.Setenv(name, "") @@ -168,5 +263,237 @@ func (s *argsTestSuite) TestBooleanOption() { os.Setenv(name, "TRUE") check(true, true, false, "should handle true value case not sensitive") check(false, true, false, "should handle true value case not sensitive") - +} + +func (s *argsTestSuite) TestDetailString() { + home := "/home" + name := "test-name" + allowDownloadBinaries := true + restartAfterUpgrade := true + pollInterval := 406 * time.Millisecond + unsafeSkipBackup := false + preupgradeMaxRetries := 8 + cfg := &Config{ + Home: home, + Name: name, + AllowDownloadBinaries: allowDownloadBinaries, + RestartAfterUpgrade: restartAfterUpgrade, + PollInterval: pollInterval, + UnsafeSkipBackup: unsafeSkipBackup, + PreupgradeMaxRetries: preupgradeMaxRetries, + } + + expectedPieces := []string{ + "Configurable Values:", + fmt.Sprintf("%s: %s", EnvHome, home), + fmt.Sprintf("%s: %s", EnvName, name), + fmt.Sprintf("%s: %t", EnvDownloadBin, allowDownloadBinaries), + fmt.Sprintf("%s: %t", EnvRestartUpgrade, restartAfterUpgrade), + fmt.Sprintf("%s: %s", EnvInterval, pollInterval), + fmt.Sprintf("%s: %t", EnvSkipBackup, unsafeSkipBackup), + fmt.Sprintf("%s: %d", EnvPreupgradeMaxRetries, preupgradeMaxRetries), + "Derived Values:", + fmt.Sprintf("Root Dir: %s", home), + fmt.Sprintf("Upgrade Dir: %s", home), + fmt.Sprintf("Genesis Bin: %s", home), + fmt.Sprintf("Monitored File: %s", home), + } + + actual := cfg.DetailString() + + for _, piece := range expectedPieces { + s.Assert().Contains(actual, piece) + } +} + +func (s *argsTestSuite) TestGetConfigFromEnv() { + initialEnv := s.clearEnv() + defer s.setEnv(nil, initialEnv) + + relPath := filepath.Join("testdata", "validate") + absPath, perr := filepath.Abs(relPath) + s.Require().NoError(perr) + + newConfig := func(home, name string, downloadBin, restartUpgrade, skipBackup bool, interval, preupgradeMaxRetries int) *Config { + return &Config{ + Home: home, + Name: name, + AllowDownloadBinaries: downloadBin, + RestartAfterUpgrade: restartUpgrade, + PollInterval: time.Millisecond * time.Duration(interval), + UnsafeSkipBackup: skipBackup, + PreupgradeMaxRetries: preupgradeMaxRetries, + } + } + + tests := []struct { + name string + envVals cosmovisorEnv + expectedCfg *Config + expectedErrCount int + }{ + // EnvHome, EnvName, EnvDownloadBin, EnvRestartUpgrade, EnvSkipBackup, EnvInterval, EnvPreupgradeMaxRetries + { + name: "all bad", + envVals: cosmovisorEnv{"", "", "bad", "bad", "bad", "bad", "bad"}, + expectedCfg: nil, + expectedErrCount: 7, + }, + { + name: "all good", + envVals: cosmovisorEnv{absPath, "testname", "true", "false", "true", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", true, false, true, 303, 1), + expectedErrCount: 0, + }, + { + name: "nothing set", + envVals: cosmovisorEnv{"", "", "", "", "", "", ""}, + expectedCfg: nil, + expectedErrCount: 2, + }, + // Note: Home and Name tests are done in TestValidate + { + name: "download bin bad", + envVals: cosmovisorEnv{absPath, "testname", "bad", "false", "true", "303", "1"}, + expectedCfg: nil, + expectedErrCount: 1, + }, + { + name: "download bin not set", + envVals: cosmovisorEnv{absPath, "testname", "", "false", "true", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", false, false, true, 303, 1), + expectedErrCount: 0, + }, + { + name: "download bin true", + envVals: cosmovisorEnv{absPath, "testname", "true", "false", "true", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", true, false, true, 303, 1), + expectedErrCount: 0, + }, + { + name: "download bin false", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "true", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", false, false, true, 303, 1), + expectedErrCount: 0, + }, + // EnvHome, EnvName, EnvDownloadBin, EnvRestartUpgrade, EnvSkipBackup, EnvInterval, EnvPreupgradeMaxRetries + { + name: "restart upgrade bad", + envVals: cosmovisorEnv{absPath, "testname", "true", "bad", "true", "303", "1"}, + expectedCfg: nil, + expectedErrCount: 1, + }, + { + name: "restart upgrade not set", + envVals: cosmovisorEnv{absPath, "testname", "true", "", "true", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", true, true, true, 303, 1), + expectedErrCount: 0, + }, + { + name: "restart upgrade true", + envVals: cosmovisorEnv{absPath, "testname", "true", "true", "true", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", true, true, true, 303, 1), + expectedErrCount: 0, + }, + { + name: "restart upgrade true", + envVals: cosmovisorEnv{absPath, "testname", "true", "false", "true", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", true, false, true, 303, 1), + expectedErrCount: 0, + }, + // EnvHome, EnvName, EnvDownloadBin, EnvRestartUpgrade, EnvSkipBackup, EnvInterval, EnvPreupgradeMaxRetries + { + name: "skip unsafe backups bad", + envVals: cosmovisorEnv{absPath, "testname", "true", "false", "bad", "303", "1"}, + expectedCfg: nil, + expectedErrCount: 1, + }, + { + name: "skip unsafe backups not set", + envVals: cosmovisorEnv{absPath, "testname", "true", "false", "", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", true, false, false, 303, 1), + expectedErrCount: 0, + }, + { + name: "skip unsafe backups true", + envVals: cosmovisorEnv{absPath, "testname", "true", "false", "true", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", true, false, true, 303, 1), + expectedErrCount: 0, + }, + { + name: "skip unsafe backups false", + envVals: cosmovisorEnv{absPath, "testname", "true", "false", "false", "303", "1"}, + expectedCfg: newConfig(absPath, "testname", true, false, false, 303, 1), + expectedErrCount: 0, + }, + // EnvHome, EnvName, EnvDownloadBin, EnvRestartUpgrade, EnvSkipBackup, EnvInterval, EnvPreupgradeMaxRetries + { + name: "poll interval bad", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "false", "bad", "1"}, + expectedCfg: nil, + expectedErrCount: 1, + }, + { + name: "poll interval 0", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "false", "0", "1"}, + expectedCfg: nil, + expectedErrCount: 1, + }, + { + name: "poll interval not set", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "false", "", "1"}, + expectedCfg: newConfig(absPath, "testname", false, false, false, 300, 1), + expectedErrCount: 0, + }, + { + name: "poll interval 987", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "false", "987", "1"}, + expectedCfg: newConfig(absPath, "testname", false, false, false, 987, 1), + expectedErrCount: 0, + }, + // EnvHome, EnvName, EnvDownloadBin, EnvRestartUpgrade, EnvSkipBackup, EnvInterval, EnvPreupgradeMaxRetries + { + name: "prepupgrade max retries bad", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "false", "406", "bad"}, + expectedCfg: nil, + expectedErrCount: 1, + }, + { + name: "prepupgrade max retries 0", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "false", "406", "0"}, + expectedCfg: newConfig(absPath, "testname", false, false, false, 406, 0), + expectedErrCount: 0, + }, + { + name: "prepupgrade max retries not set", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "false", "406", ""}, + expectedCfg: newConfig(absPath, "testname", false, false, false, 406, 0), + expectedErrCount: 0, + }, + { + name: "prepupgrade max retries 5", + envVals: cosmovisorEnv{absPath, "testname", "false", "false", "false", "406", "5"}, + expectedCfg: newConfig(absPath, "testname", false, false, false, 406, 5), + expectedErrCount: 0, + }, + } + + for _, tc := range tests { + s.T().Run(tc.name, func(t *testing.T) { + s.setEnv(t, &tc.envVals) + cfg, err := GetConfigFromEnv() + if tc.expectedErrCount == 0 { + assert.NoError(t, err) + } else { + if assert.Error(t, err) { + errCount := 1 + if multi, isMulti := err.(*errors.MultiError); isMulti { + errCount = multi.Len() + } + assert.Equal(t, tc.expectedErrCount, errCount, "error count") + } + } + assert.Equal(t, tc.expectedCfg, cfg, "config") + }) + } } diff --git a/cosmovisor/cmd/cosmovisor/cmd/help.go b/cosmovisor/cmd/cosmovisor/cmd/help.go new file mode 100644 index 0000000000..460c21d7e2 --- /dev/null +++ b/cosmovisor/cmd/cosmovisor/cmd/help.go @@ -0,0 +1,49 @@ +package cmd + +import ( + "fmt" + "os" + "strings" + + "github.com/cosmos/cosmos-sdk/cosmovisor" +) + +// ShouldGiveHelp checks the env and provided args to see if help is needed or being requested. +// Help is needed if either cosmovisor.EnvName and/or cosmovisor.EnvHome env vars aren't set. +// Help is requested if any args are "help", "--help", or "-h". +func ShouldGiveHelp(args []string) bool { + if len(os.Getenv(cosmovisor.EnvName)) == 0 || len(os.Getenv(cosmovisor.EnvHome)) == 0 { + return true + } + if len(args) == 0 { + return false + } + for _, arg := range args { + if strings.EqualFold(arg, "help") || strings.EqualFold(arg, "--help") || strings.EqualFold(arg, "-h") { + return true + } + } + return false +} + +// DoHelp outputs help text +func DoHelp() { + // Not using the logger for this output because the header and footer look weird for help text. + fmt.Println(GetHelpText()) +} + +// GetHelpText creates the help text multi-line string. +func GetHelpText() string { + return fmt.Sprintf(`Cosmosvisor - A process manager for Cosmos SDK application binaries. + +Cosmovisor is a wrapper for a Cosmos SDK based App (set using the required %s env variable). +It starts the App by passing all provided arguments and monitors the %s/data/upgrade-info.json +file to perform an update. The upgrade-info.json file is created by the App x/upgrade module +when the blockchain height reaches an approved upgrade proposal. The file includes data from +the proposal. Cosmovisor interprets that data to perform an update: switch a current binary +and restart the App. + +Configuration of Cosmovisor is done through environment variables, which are +documented in: https://github.com/cosmos/cosmos-sdk/tree/master/cosmovisor/README.md +`, cosmovisor.EnvName, cosmovisor.EnvHome) +} diff --git a/cosmovisor/cmd/cosmovisor/cmd/help_test.go b/cosmovisor/cmd/cosmovisor/cmd/help_test.go new file mode 100644 index 0000000000..91a152e4d9 --- /dev/null +++ b/cosmovisor/cmd/cosmovisor/cmd/help_test.go @@ -0,0 +1,276 @@ +package cmd + +import ( + "fmt" + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/cosmos/cosmos-sdk/cosmovisor" +) + +type HelpTestSuite struct { + suite.Suite +} + +func TestHelpTestSuite(t *testing.T) { + suite.Run(t, new(HelpTestSuite)) +} + +// cosmovisorHelpEnv are some string values of environment variables used to configure Cosmovisor. +type cosmovisorHelpEnv struct { + Home string + Name string +} + +// ToMap creates a map of the cosmovisorHelpEnv where the keys are the env var names. +func (c cosmovisorHelpEnv) ToMap() map[string]string { + return map[string]string{ + cosmovisor.EnvHome: c.Home, + cosmovisor.EnvName: c.Name, + } +} + +// Set sets the field in this cosmovisorHelpEnv corresponding to the provided envVar to the given envVal. +func (c *cosmovisorHelpEnv) Set(envVar, envVal string) { + switch envVar { + case cosmovisor.EnvHome: + c.Home = envVal + case cosmovisor.EnvName: + c.Name = envVal + default: + panic(fmt.Errorf("Unknown environment variable [%s]. Ccannot set field to [%s]. ", envVar, envVal)) + } +} + +// clearEnv clears environment variables and returns what they were. +// Designed to be used like this: +// initialEnv := clearEnv() +// defer setEnv(nil, initialEnv) +func (s *HelpTestSuite) clearEnv() *cosmovisorHelpEnv { + s.T().Logf("Clearing environment variables.") + rv := cosmovisorHelpEnv{} + for envVar := range rv.ToMap() { + rv.Set(envVar, os.Getenv(envVar)) + s.Require().NoError(os.Unsetenv(envVar)) + } + return &rv +} + +// setEnv sets environment variables to the values provided. +// If t is not nil, and there's a problem, the test will fail immediately. +// If t is nil, problems will just be logged using s.T(). +func (s *HelpTestSuite) setEnv(t *testing.T, env *cosmovisorHelpEnv) { + if t == nil { + s.T().Logf("Restoring environment variables.") + } + for envVar, envVal := range env.ToMap() { + var err error + var msg string + if len(envVal) != 0 { + err = os.Setenv(envVar, envVal) + msg = fmt.Sprintf("setting %s to %s", envVar, envVal) + } else { + err = os.Unsetenv(envVar) + msg = fmt.Sprintf("unsetting %s", envVar) + } + switch { + case t != nil: + require.NoError(t, err, msg) + case err != nil: + s.T().Logf("error %s: %v", msg, err) + default: + s.T().Logf("done %s", msg) + } + } +} + +func (s *HelpTestSuite) TestShouldGiveHelpEnvVars() { + initialEnv := s.clearEnv() + defer s.setEnv(nil, initialEnv) + + emptyVal := "" + homeVal := "/somehome" + nameVal := "somename" + + tests := []struct { + name string + envHome *string + envName *string + expected bool + }{ + { + name: "home set name set", + envHome: &homeVal, + envName: &nameVal, + expected: false, + }, + { + name: "home not set name not set", + envHome: nil, + envName: nil, + expected: true, + }, + { + name: "home empty name not set", + envHome: &emptyVal, + envName: nil, + expected: true, + }, + { + name: "home set name not set", + envHome: &homeVal, + envName: nil, + expected: true, + }, + { + name: "home not set name empty", + envHome: nil, + envName: &emptyVal, + expected: true, + }, + { + name: "home empty name empty", + envHome: &emptyVal, + envName: &emptyVal, + expected: true, + }, + { + name: "home set name empty", + envHome: &homeVal, + envName: &emptyVal, + expected: true, + }, + { + name: "home not set name set", + envHome: nil, + envName: &nameVal, + expected: true, + }, + { + name: "home empty name set", + envHome: &emptyVal, + envName: &nameVal, + expected: true, + }, + } + + prepEnv := func(t *testing.T, envVar string, envVal *string) { + if envVal == nil { + require.NoError(t, os.Unsetenv(cosmovisor.EnvHome)) + } else { + require.NoError(t, os.Setenv(envVar, *envVal)) + } + } + + for _, tc := range tests { + s.T().Run(tc.name, func(t *testing.T) { + prepEnv(t, cosmovisor.EnvHome, tc.envHome) + prepEnv(t, cosmovisor.EnvName, tc.envName) + actual := ShouldGiveHelp(nil) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func (s HelpTestSuite) TestShouldGiveHelpArgs() { + initialEnv := s.clearEnv() + defer s.setEnv(nil, initialEnv) + + s.setEnv(s.T(), &cosmovisorHelpEnv{"/testhome", "testname"}) + + tests := []struct { + name string + args []string + expected bool + }{ + { + name: "nil args", + args: nil, + expected: false, + }, + { + name: "empty args", + args: []string{}, + expected: false, + }, + { + name: "one arg random", + args: []string{"random"}, + expected: false, + }, + { + name: "five args random", + args: []string{"random1", "--random2", "-r", "random4", "-random5"}, + expected: false, + }, + { + name: "one arg help", + args: []string{"help"}, + expected: true, + }, + { + name: " two args help first", + args: []string{"help", "arg2"}, + expected: true, + }, + { + name: "two args help second", + args: []string{"arg1", "help"}, + expected: true, + }, + { + name: "one arg -h", + args: []string{"-h"}, + expected: true, + }, + { + name: "two args -h first", + args: []string{"-h", "arg2"}, + expected: true, + }, + { + name: "two args -h second", + args: []string{"arg1", "-h"}, + expected: true, + }, + { + name: "one arg --help", + args: []string{"--help"}, + expected: true, + }, + { + name: "two args --help first", + args: []string{"--help", "arg2"}, + expected: true, + }, + { + name: "two args --help second", + args: []string{"arg1", "--help"}, + expected: true, + }, + } + + for _, tc := range tests { + s.T().Run(tc.name, func(t *testing.T) { + actual := ShouldGiveHelp(tc.args) + assert.Equal(t, tc.expected, actual) + }) + } +} + +func (s *HelpTestSuite) TestGetHelpText() { + expectedPieces := []string{ + "Cosmosvisor", + cosmovisor.EnvName, cosmovisor.EnvHome, + "https://github.com/cosmos/cosmos-sdk/tree/master/cosmovisor/README.md", + } + + actual := GetHelpText() + for _, piece := range expectedPieces { + s.Assert().Contains(actual, piece) + } +} diff --git a/cosmovisor/cmd/cosmovisor/cmd/root.go b/cosmovisor/cmd/cosmovisor/cmd/root.go index 1b1135c363..3bea433f20 100644 --- a/cosmovisor/cmd/cosmovisor/cmd/root.go +++ b/cosmovisor/cmd/cosmovisor/cmd/root.go @@ -1,8 +1,12 @@ package cmd // RunCosmovisorCommands executes cosmosvisor commands e.g `cosmovisor version` +// Returned boolean is whether or not execution should continue. func RunCosmovisorCommands(args []string) { - if isVersionCommand(args) { + switch { + case ShouldGiveHelp(args): + DoHelp() + case isVersionCommand(args): printVersion() } } diff --git a/cosmovisor/cmd/cosmovisor/main.go b/cosmovisor/cmd/cosmovisor/main.go index c0e9a97f41..07de6d0123 100644 --- a/cosmovisor/cmd/cosmovisor/main.go +++ b/cosmovisor/cmd/cosmovisor/main.go @@ -1,10 +1,12 @@ package main import ( + "fmt" "os" "github.com/cosmos/cosmos-sdk/cosmovisor" "github.com/cosmos/cosmos-sdk/cosmovisor/cmd/cosmovisor/cmd" + "github.com/cosmos/cosmos-sdk/cosmovisor/errors" ) func main() { @@ -19,10 +21,20 @@ func main() { func Run(args []string) error { cmd.RunCosmovisorCommands(args) - cfg, err := cosmovisor.GetConfigFromEnv() - if err != nil { - return err + cfg, cerr := cosmovisor.GetConfigFromEnv() + if cerr != nil { + switch err := cerr.(type) { + case *errors.MultiError: + cosmovisor.Logger.Error().Msg("multiple configuration errors found:") + for i, e := range err.GetErrors() { + cosmovisor.Logger.Error().Err(e).Msg(fmt.Sprintf(" %d:", i+1)) + } + default: + cosmovisor.Logger.Error().Err(err).Msg("configuration error:") + } + return cerr } + cosmovisor.Logger.Info().Msg("Configuration is valid:\n" + cfg.DetailString()) launcher, err := cosmovisor.NewLauncher(cfg) if err != nil { return err diff --git a/cosmovisor/errors/multi.go b/cosmovisor/errors/multi.go new file mode 100644 index 0000000000..3b1d419ea2 --- /dev/null +++ b/cosmovisor/errors/multi.go @@ -0,0 +1,69 @@ +package errors + +import ( + "fmt" + "strings" +) + +// MultiError is an error combining multiple other errors. +// It will never have 0 or 1 errors. It will always have two or more. +type MultiError struct { + errs []error +} + +// FlattenErrors possibly creates a MultiError. +// Nil entries are ignored. +// If all provided errors are nil (or nothing is provided), nil is returned. +// If only one non-nil error is provided, it is returned unchanged. +// If two or more non-nil errors are provided, the returned error will be of type *MultiError +// and it will contain each non-nil error. +func FlattenErrors(errs ...error) error { + rv := MultiError{} + for _, err := range errs { + if err != nil { + if merr, isMerr := err.(*MultiError); isMerr { + rv.errs = append(rv.errs, merr.errs...) + } else { + rv.errs = append(rv.errs, err) + } + } + } + switch rv.Len() { + case 0: + return nil + case 1: + return rv.errs[0] + } + return &rv +} + +// GetErrors gets all the errors that make up this MultiError. +func (e MultiError) GetErrors() []error { + // Return a copy of the errs slice to prevent alteration of the original slice. + rv := make([]error, e.Len()) + copy(rv, e.errs) + return rv +} + +// Len gets the number of errors in this MultiError. +func (e MultiError) Len() int { + return len(e.errs) +} + +// Error implements the error interface for a MultiError. +func (e *MultiError) Error() string { + var sb strings.Builder + sb.WriteString(fmt.Sprintf("%d errors: ", len(e.errs))) + for i, err := range e.errs { + if i != 0 { + sb.WriteString(", ") + } + sb.WriteString(fmt.Sprintf("%d: %v", i+1, err)) + } + return sb.String() +} + +// String implements the string interface for a MultiError. +func (e MultiError) String() string { + return e.Error() +} diff --git a/cosmovisor/errors/multi_test.go b/cosmovisor/errors/multi_test.go new file mode 100644 index 0000000000..83454b2cfb --- /dev/null +++ b/cosmovisor/errors/multi_test.go @@ -0,0 +1,190 @@ +package errors + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" +) + +type MultiErrorTestSuite struct { + suite.Suite + + err1 error + err2 error + err3 error + err4 error +} + +func TestMultiErrorTestSuite(t *testing.T) { + suite.Run(t, new(MultiErrorTestSuite)) +} + +func (s *MultiErrorTestSuite) SetupTest() { + s.err1 = errors.New("expected error one") + s.err2 = errors.New("expected error two") + s.err3 = errors.New("expected error three") + s.err3 = errors.New("expected error four") +} + +func (s *MultiErrorTestSuite) TestFlattenErrors() { + tests := []struct { + name string + input []error + expected error + }{ + { + name: "none in nil out", + input: []error{}, + expected: nil, + }, + { + name: "nil in nil out", + input: []error{nil}, + expected: nil, + }, + { + name: "nils in nil out", + input: []error{nil, nil, nil}, + expected: nil, + }, + { + name: "one in same out", + input: []error{s.err1}, + expected: s.err1, + }, + { + name: "nils and one in that one out", + input: []error{nil, s.err2, nil}, + expected: s.err2, + }, + { + name: "two in multi out with both", + input: []error{s.err1, s.err2}, + expected: &MultiError{errs: []error{s.err1, s.err2}}, + }, + { + name: "two and nils in multi out with both", + input: []error{nil, s.err1, nil, s.err2, nil}, + expected: &MultiError{errs: []error{s.err1, s.err2}}, + }, + { + name: "lots in multi out", + input: []error{s.err1, s.err2, s.err3, s.err2, s.err1}, + expected: &MultiError{errs: []error{s.err1, s.err2, s.err3, s.err2, s.err1}}, + }, + { + name: "multi and non in one multi out with all", + input: []error{&MultiError{errs: []error{s.err1, s.err2}}, s.err3}, + expected: &MultiError{errs: []error{s.err1, s.err2, s.err3}}, + }, + { + name: "non and multi in one multi out with all", + input: []error{s.err1, &MultiError{errs: []error{s.err2, s.err3}}}, + expected: &MultiError{errs: []error{s.err1, s.err2, s.err3}}, + }, + { + name: "two multi in one multi out with all", + input: []error{&MultiError{errs: []error{s.err1, s.err2}}, &MultiError{errs: []error{s.err3, s.err4}}}, + expected: &MultiError{errs: []error{s.err1, s.err2, s.err3, s.err4}}, + }, + } + + for _, tc := range tests { + s.T().Run(tc.name, func(t *testing.T) { + actual := FlattenErrors(tc.input...) + require.Equal(t, tc.expected, actual) + }) + } +} + +func (s *MultiErrorTestSuite) TestGetErrors() { + tests := []struct { + name string + multi MultiError + expected []error + }{ + { + name: "two", + multi: MultiError{errs: []error{s.err3, s.err1}}, + expected: []error{s.err3, s.err1}, + }, + { + name: "three", + multi: MultiError{errs: []error{s.err3, s.err1, s.err2}}, + expected: []error{s.err3, s.err1, s.err2}, + }, + } + + for _, tc := range tests { + s.T().Run(tc.name, func(t *testing.T) { + // Make sure it's getting what's expected. + actual1 := tc.multi.GetErrors() + require.NotSame(t, tc.expected, actual1) + require.Equal(t, tc.expected, actual1) + // Make sure that changing what was given back doesn't alter the original. + actual1[0] = errors.New("unexpected error") + actual2 := tc.multi.GetErrors() + require.NotEqual(t, actual1, actual2) + require.Equal(t, tc.expected, actual2) + }) + } +} + +func (s *MultiErrorTestSuite) TestLen() { + tests := []struct { + name string + multi MultiError + expected int + }{ + { + name: "two", + multi: MultiError{errs: []error{s.err3, s.err1}}, + expected: 2, + }, + { + name: "three", + multi: MultiError{errs: []error{s.err3, s.err1, s.err2}}, + expected: 3, + }, + } + + for _, tc := range tests { + s.T().Run(tc.name, func(t *testing.T) { + actual := tc.multi.Len() + require.Equal(t, tc.expected, actual) + }) + } +} + +func (s *MultiErrorTestSuite) TestErrorAndString() { + tests := []struct { + name string + multi MultiError + expected string + }{ + { + name: "two", + multi: MultiError{errs: []error{s.err1, s.err2}}, + expected: fmt.Sprintf("2 errors: 1: %s, 2: %s", s.err1, s.err2), + }, + { + name: "three", + multi: MultiError{errs: []error{s.err1, s.err2, s.err3}}, + expected: fmt.Sprintf("3 errors: 1: %s, 2: %s, 3: %s", s.err1, s.err2, s.err3), + }, + } + + for _, tc := range tests { + s.T().Run(tc.name+" Error", func(t *testing.T) { + actual := tc.multi.Error() + require.Equal(t, tc.expected, actual) + }) + s.T().Run(tc.name+" String", func(t *testing.T) { + actual := tc.multi.String() + require.Equal(t, tc.expected, actual) + }) + } +}