cosmos-sdk/tools/cosmovisor/args.go
psiphi5 5b53ccaf0c
feat(tools/cosmovisor): cosmovisor batch upgrades (#21790)
Co-authored-by: Julien Robert <julien@rbrt.fr>
2024-10-10 07:31:29 +00:00

637 lines
19 KiB
Go

package cosmovisor
import (
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"strconv"
"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"
)
// environment variable names
const (
EnvHome = "DAEMON_HOME"
EnvName = "DAEMON_NAME"
EnvDownloadBin = "DAEMON_ALLOW_DOWNLOAD_BINARIES"
EnvDownloadMustHaveChecksum = "DAEMON_DOWNLOAD_MUST_HAVE_CHECKSUM"
EnvRestartUpgrade = "DAEMON_RESTART_AFTER_UPGRADE"
EnvRestartDelay = "DAEMON_RESTART_DELAY"
EnvShutdownGrace = "DAEMON_SHUTDOWN_GRACE"
EnvSkipBackup = "UNSAFE_SKIP_BACKUP"
EnvDataBackupPath = "DAEMON_DATA_BACKUP_DIR"
EnvInterval = "DAEMON_POLL_INTERVAL"
EnvPreupgradeMaxRetries = "DAEMON_PREUPGRADE_MAX_RETRIES"
EnvGRPCAddress = "DAEMON_GRPC_ADDRESS"
EnvDisableLogs = "COSMOVISOR_DISABLE_LOGS"
EnvColorLogs = "COSMOVISOR_COLOR_LOGS"
EnvTimeFormatLogs = "COSMOVISOR_TIMEFORMAT_LOGS"
EnvCustomPreupgrade = "COSMOVISOR_CUSTOM_PREUPGRADE"
EnvDisableRecase = "COSMOVISOR_DISABLE_RECASE"
)
const (
rootName = "cosmovisor"
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 `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"`
GRPCAddress string `toml:"daemon_grpc_address" mapstructure:"daemon_grpc_address"`
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
}
// Root returns the root directory where all info lives
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)
}
// UpgradeBin is the path to the binary for the named upgrade
func (cfg *Config) UpgradeBin(upgradeName string) string {
return filepath.Join(cfg.UpgradeDir(upgradeName), "bin", cfg.Name)
}
// UpgradeDir is the directory named upgrade
func (cfg *Config) UpgradeDir(upgradeName string) string {
safeName := url.PathEscape(upgradeName)
return filepath.Join(cfg.BaseUpgradeDir(), safeName)
}
// 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", upgradetypes.UpgradeInfoFilename)
}
// UpgradeInfoBatchFilePath is the same as UpgradeInfoFilePath but with a batch suffix.
func (cfg *Config) UpgradeInfoBatchFilePath() string {
return cfg.UpgradeInfoFilePath() + ".batch"
}
// SymLinkToGenesis creates a symbolic link from "./current" to the genesis directory.
func (cfg *Config) SymLinkToGenesis() (string, error) {
// workdir is set to cosmovisor directory so relative
// symlinks are getting resolved correctly
if err := os.Symlink(genesisDir, currentLink); err != nil {
return "", err
}
res, err := filepath.EvalSymlinks(cfg.GenesisBin())
if err != nil {
return "", err
}
// and return the genesis binary
return res, nil
}
// WaitRestartDelay will block and wait until the RestartDelay has elapsed.
func (cfg *Config) WaitRestartDelay() {
if cfg.RestartDelay > 0 {
time.Sleep(cfg.RestartDelay)
}
}
// CurrentBin is the path to the currently selected binary (genesis if no link is set)
// This will resolve the symlink to the underlying directory to make it easier to debug
func (cfg *Config) CurrentBin() (string, error) {
cur := filepath.Join(cfg.Root(), currentLink)
// if nothing here, fallback to genesis
// if it is there, ensure it is a symlink
info, err := os.Lstat(cur)
if err != nil || (info.Mode()&os.ModeSymlink == 0) {
// Create symlink to the genesis
return cfg.SymLinkToGenesis()
}
res, err := filepath.EvalSymlinks(cur)
if err != nil {
// Create symlink to the genesis
return cfg.SymLinkToGenesis()
}
// and return the binary
binpath := filepath.Join(res, "bin", cfg.Name)
return binpath, nil
}
// GetConfigFromFile will read the configuration from the config file at the given path.
// If the file path is not provided, it will 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(false)
}
// ensure the file exist
if _, err := os.Stat(filePath); err != nil {
return nil, fmt.Errorf("config not found: at %s : %w", filePath, err)
}
v := viper.New()
// read the configuration from the file
v.SetConfigFile(filePath)
// load the env variables
// if the env variable is set, it will override the value provided by the config
v.AutomaticEnv()
if err := v.ReadInConfig(); err != nil {
return nil, fmt.Errorf("failed to read config file: %w", err)
}
cfg := &Config{}
if err := v.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(skipValidate bool) (*Config, error) {
var errs []error
cfg := &Config{
Home: os.Getenv(EnvHome),
Name: os.Getenv(EnvName),
DataBackupPath: os.Getenv(EnvDataBackupPath),
CustomPreUpgrade: os.Getenv(EnvCustomPreupgrade),
}
if cfg.DataBackupPath == "" {
cfg.DataBackupPath = cfg.Home
}
var err error
if cfg.AllowDownloadBinaries, err = BooleanOption(EnvDownloadBin, false); err != nil {
errs = append(errs, err)
}
if cfg.DownloadMustHaveChecksum, err = BooleanOption(EnvDownloadMustHaveChecksum, false); err != nil {
errs = append(errs, err)
}
if cfg.RestartAfterUpgrade, err = BooleanOption(EnvRestartUpgrade, true); err != nil {
errs = append(errs, err)
}
if cfg.UnsafeSkipBackup, err = BooleanOption(EnvSkipBackup, false); err != nil {
errs = append(errs, err)
}
if cfg.DisableLogs, err = BooleanOption(EnvDisableLogs, false); err != nil {
errs = append(errs, err)
}
if cfg.ColorLogs, err = BooleanOption(EnvColorLogs, true); err != nil {
errs = append(errs, err)
}
if cfg.TimeFormatLogs, err = TimeFormatOptionFromEnv(EnvTimeFormatLogs, time.Kitchen); err != nil {
errs = append(errs, err)
}
if cfg.DisableRecase, err = BooleanOption(EnvDisableRecase, false); err != nil {
errs = append(errs, err)
}
interval := os.Getenv(EnvInterval)
if interval != "" {
val, err := parseEnvDuration(interval)
if err != nil {
errs = append(errs, fmt.Errorf("invalid: %s: %w", EnvInterval, err))
} else {
cfg.PollInterval = val
}
} else {
cfg.PollInterval = 300 * time.Millisecond
}
cfg.RestartDelay = 0 // default value but makes it explicit
restartDelay := os.Getenv(EnvRestartDelay)
if restartDelay != "" {
val, err := parseEnvDuration(restartDelay)
if err != nil {
errs = append(errs, fmt.Errorf("invalid: %s: %w", EnvRestartDelay, err))
} else {
cfg.RestartDelay = val
}
}
cfg.ShutdownGrace = 0 // default value but makes it explicit
shutdownGrace := os.Getenv(EnvShutdownGrace)
if shutdownGrace != "" {
val, err := parseEnvDuration(shutdownGrace)
if err != nil {
errs = append(errs, fmt.Errorf("invalid: %s: %w", EnvShutdownGrace, err))
} else {
cfg.ShutdownGrace = val
}
}
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))
}
cfg.GRPCAddress = os.Getenv(EnvGRPCAddress)
if cfg.GRPCAddress == "" {
cfg.GRPCAddress = "localhost:9090"
}
if !skipValidate {
errs = append(errs, cfg.validate()...)
if len(errs) > 0 {
return nil, errors.Join(errs...)
}
}
return cfg, nil
}
func (cfg *Config) Logger(dst io.Writer) log.Logger {
var logger log.Logger
if cfg.DisableLogs {
logger = log.NewNopLogger()
} else {
logger = log.NewLogger(dst,
log.ColorOption(cfg.ColorLogs),
log.TimeFormatOption(cfg.TimeFormatLogs)).With(log.ModuleKey, "cosmovisor")
}
return logger
}
func parseEnvDuration(input string) (time.Duration, error) {
duration, err := time.ParseDuration(input)
if err != nil {
return 0, fmt.Errorf("could not parse '%s' into a duration: %w", input, err)
}
if duration <= 0 {
return 0, errors.New("must be greater than 0")
}
return duration, 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 {
var errs []error
// validate EnvName
if cfg.Name == "" {
errs = append(errs, fmt.Errorf("%s is not set", EnvName))
}
// validate EnvHome
switch {
case cfg.Home == "":
errs = append(errs, fmt.Errorf("%s is not set", EnvHome))
case !filepath.IsAbs(cfg.Home):
errs = append(errs, fmt.Errorf("%s must be an absolute path", EnvHome))
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()))
}
}
// check the DataBackupPath
if cfg.UnsafeSkipBackup {
return errs
}
// if UnsafeSkipBackup is false, validate DataBackupPath
switch {
case cfg.DataBackupPath == "":
errs = append(errs, fmt.Errorf("%s must not be empty", EnvDataBackupPath))
case !filepath.IsAbs(cfg.DataBackupPath):
errs = append(errs, fmt.Errorf("%s must be an absolute path", cfg.DataBackupPath))
default:
switch info, err := os.Stat(cfg.DataBackupPath); {
case err != nil:
errs = append(errs, fmt.Errorf("%q must be a valid directory: %w", cfg.DataBackupPath, err))
case !info.IsDir():
errs = append(errs, fmt.Errorf("%q must be a valid directory", cfg.DataBackupPath))
}
}
return errs
}
// SetCurrentUpgrade sets the named upgrade to be the current link, returns error if this binary doesn't exist
func (cfg *Config) SetCurrentUpgrade(u upgradetypes.Plan) (rerr error) {
// ensure named upgrade exists
bin := cfg.UpgradeBin(u.Name)
if err := plan.EnsureBinary(bin); err != nil {
return err
}
// set a symbolic link
safeName := url.PathEscape(u.Name)
upgrade := filepath.Join(upgradesDir, safeName)
// remove link if it exists
if _, err := os.Stat(currentLink); err == nil {
if err := os.Remove(currentLink); err != nil {
return fmt.Errorf("failed to remove existing link: %w", err)
}
}
// point to the new directory
if err := os.Symlink(upgrade, currentLink); err != nil {
return fmt.Errorf("creating current symlink: %w", err)
}
cfg.currentUpgrade = u
f, err := os.Create(filepath.Join(cfg.Root(), upgrade, upgradetypes.UpgradeInfoFilename))
if err != nil {
return err
}
defer func() {
cerr := f.Close()
if rerr == nil {
rerr = cerr
}
}()
bz, err := json.Marshal(u)
if err != nil {
return err
}
_, err = f.Write(bz)
return err
}
// UpgradeInfo returns the current upgrade info
func (cfg *Config) UpgradeInfo() (upgradetypes.Plan, error) {
if cfg.currentUpgrade.Name != "" {
return cfg.currentUpgrade, nil
}
filename := filepath.Join(cfg.Root(), currentLink, upgradetypes.UpgradeInfoFilename)
_, err := os.Lstat(filename)
var u upgradetypes.Plan
var bz []byte
if err != nil { // no current directory
goto returnError
}
if bz, err = os.ReadFile(filename); err != nil {
goto returnError
}
if err = json.Unmarshal(bz, &u); err != nil {
goto returnError
}
cfg.currentUpgrade = u
return cfg.currentUpgrade, nil
returnError:
cfg.currentUpgrade.Name = "_"
return cfg.currentUpgrade, fmt.Errorf("failed to read %q: %w", filename, err)
}
// BooleanOption checks and validate env option
func BooleanOption(name string, defaultVal bool) (bool, error) {
p := strings.ToLower(os.Getenv(name))
switch p {
case "":
return defaultVal, nil
case "false":
return false, nil
case "true":
return true, nil
}
return false, fmt.Errorf("env variable %q must have a boolean value (\"true\" or \"false\"), got %q", name, p)
}
// 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
case "ansic":
return time.ANSIC, nil
case "unixdate":
return time.UnixDate, nil
case "rubydate":
return time.RubyDate, nil
case "rfc822":
return time.RFC822, nil
case "rfc822z":
return time.RFC822Z, nil
case "rfc850":
return time.RFC850, nil
case "rfc1123":
return time.RFC1123, nil
case "rfc1123z":
return time.RFC1123Z, nil
case "rfc3339":
return time.RFC3339, nil
case "rfc3339nano":
return time.RFC3339Nano, nil
case "kitchen":
return time.Kitchen, nil
case "":
return "", nil
}
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 }{
{EnvHome, cfg.Home},
{EnvName, cfg.Name},
{EnvDownloadBin, fmt.Sprintf("%t", cfg.AllowDownloadBinaries)},
{EnvDownloadMustHaveChecksum, fmt.Sprintf("%t", cfg.DownloadMustHaveChecksum)},
{EnvRestartUpgrade, fmt.Sprintf("%t", cfg.RestartAfterUpgrade)},
{EnvRestartDelay, cfg.RestartDelay.String()},
{EnvShutdownGrace, cfg.ShutdownGrace.String()},
{EnvInterval, cfg.PollInterval.String()},
{EnvSkipBackup, fmt.Sprintf("%t", cfg.UnsafeSkipBackup)},
{EnvDataBackupPath, cfg.DataBackupPath},
{EnvPreupgradeMaxRetries, fmt.Sprintf("%d", cfg.PreUpgradeMaxRetries)},
{EnvDisableLogs, fmt.Sprintf("%t", cfg.DisableLogs)},
{EnvColorLogs, fmt.Sprintf("%t", cfg.ColorLogs)},
{EnvTimeFormatLogs, cfg.TimeFormatLogs},
{EnvCustomPreupgrade, cfg.CustomPreUpgrade},
{EnvDisableRecase, fmt.Sprintf("%t", cfg.DisableRecase)},
}
derivedEntries := []struct{ name, value string }{
{"Root Dir", cfg.Root()},
{"Upgrade Dir", cfg.BaseUpgradeDir()},
{"Genesis Bin", cfg.GenesisBin()},
{"Monitored File", cfg.UpgradeInfoFilePath()},
{"Data Backup Dir", cfg.DataBackupPath},
}
var sb strings.Builder
sb.WriteString("Configurable Values:\n")
for _, kv := range configEntries {
fmt.Fprintf(&sb, " %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 {
fmt.Fprintf(&sb, dFmt, kv.name, kv.value)
}
return sb.String()
}
// Export exports the configuration to a file at the cosmovisor root directory.
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"
}