From e7f78b4480005275ab5a4ed1487ca2eb7cb36849 Mon Sep 17 00:00:00 2001 From: Daniel Wedul Date: Wed, 6 Jul 2022 13:35:39 -0600 Subject: [PATCH] feat(cosmovisor): Create Cosmovisor init command. (#12464) ## Description Closes: #12456 Creates an `init` command in `cosmovisor` that initializes the `DAEMON_HOME` directory with the initial executable and current link. --- ### Author Checklist *All items are required. Please add a note to the item if the item is not applicable and please add links to any relevant follow up issues.* I have... - [x] included the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title - [ ] ~~added `!` to the type prefix if API or client breaking change~~ _N/A_ - [x] targeted the correct branch (see [PR Targeting](https://github.com/cosmos/cosmos-sdk/blob/main/CONTRIBUTING.md#pr-targeting)) - [x] provided a link to the relevant issue or specification - [ ] ~~followed the guidelines for [building modules](https://github.com/cosmos/cosmos-sdk/blob/main/docs/building-modules)~~ _N/A_ - [x] included the necessary unit and integration [tests](https://github.com/cosmos/cosmos-sdk/blob/main/CONTRIBUTING.md#testing) - [x] added a changelog entry to `CHANGELOG.md` - [x] included comments for [documenting Go code](https://blog.golang.org/godoc) - [x] updated the relevant documentation or specification - [x] reviewed "Files changed" and left comments if necessary - [ ] confirmed all CI checks have passed ### Reviewers Checklist *All items are required. Please add a note if the item is not applicable and please add your handle next to the items reviewed if you only reviewed selected items.* I have... - [ ] confirmed the correct [type prefix](https://github.com/commitizen/conventional-commit-types/blob/v3.0.0/index.json) in the PR title - [ ] confirmed `!` in the type prefix if API or client breaking change - [ ] confirmed all author checklist items have been addressed - [ ] reviewed state machine logic - [ ] reviewed API design and naming - [ ] reviewed documentation is accurate - [ ] reviewed tests and test coverage - [ ] manually tested (if applicable) --- cosmovisor/CHANGELOG.md | 1 + cosmovisor/README.md | 37 +- cosmovisor/cmd/cosmovisor/init.go | 136 ++++++ cosmovisor/cmd/cosmovisor/init_test.go | 554 +++++++++++++++++++++++++ 4 files changed, 726 insertions(+), 2 deletions(-) create mode 100644 cosmovisor/cmd/cosmovisor/init.go create mode 100644 cosmovisor/cmd/cosmovisor/init_test.go diff --git a/cosmovisor/CHANGELOG.md b/cosmovisor/CHANGELOG.md index 14eddf401b51..fc38e7634b43 100644 --- a/cosmovisor/CHANGELOG.md +++ b/cosmovisor/CHANGELOG.md @@ -39,6 +39,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ ### Features +* [\#12464](https://github.com/cosmos/cosmos-sdk/pull/12464) Create the `cosmovisor init` command. * [\#12188](https://github.com/cosmos/cosmos-sdk/pull/12188) Add a `DAEMON_RESTART_DELAY` for allowing a node operator to define a delay between the node halt (for upgrade) and backup. * [\#11823](https://github.com/cosmos/cosmos-sdk/pull/11823) Refactor `cosmovisor` CLI to use `cobra`. * [\#11731](https://github.com/cosmos/cosmos-sdk/pull/11731) `cosmovisor version -o json` returns the cosmovisor version and the result of `simd --output json --long` in one JSON object. diff --git a/cosmovisor/README.md b/cosmovisor/README.md index cb4258225145..12b5e054e3b3 100644 --- a/cosmovisor/README.md +++ b/cosmovisor/README.md @@ -2,6 +2,22 @@ `cosmovisor` is a small process manager for Cosmos SDK application binaries that monitors the governance module for incoming chain upgrade proposals. If it sees a proposal that gets approved, `cosmovisor` can automatically download the new binary, stop the current binary, switch from the old binary to the new one, and finally restart the node with the new binary. + + - [Design](#design) + - [Contributing](#contributing) + - [Setup](#setup) + - [Installation](#installation) + - [Command Line Arguments And Environment Variables](#command-line-arguments-and-environment-variables) + - [Folder Layout](#folder-layout) + - [Usage](#usage) + - [Initialization](#initialization) + - [Detecting Upgrades](#detecting-upgrades) + - [Auto-Download](#auto-download) + - [Example: SimApp Upgrade](#example-simapp-upgrade) + - [Chain Setup](#chain-setup) + + + ## Design Cosmovisor is designed to be used as a wrapper for a `Cosmos SDK` app: @@ -115,8 +131,10 @@ The system administrator is responsible for: * installing the `cosmovisor` binary * configuring the host's init system (e.g. `systemd`, `launchd`, etc.) * appropriately setting the environmental variables -* manually installing the `genesis` folder -* manually installing the `upgrades/` folders +* creating the `/cosmovisor` directory +* creating the `/cosmovisor/genesis/bin` folder +* creating the `/cosmovisor/upgrades//bin` folders +* placing the different versions of the `` executable in the appropriate `bin` folders. `cosmovisor` will set the `current` link to point to `genesis` at first start (i.e. when no `current` link exists) and then handle switching binaries at the correct points in time so that the system administrator can prepare days in advance and relax at upgrade time. @@ -124,6 +142,21 @@ In order to support downloadable binaries, a tarball for each upgrade binary wil The `DAEMON` specific code and operations (e.g. tendermint config, the application db, syncing blocks, etc.) all work as expected. The application binaries' directives such as command-line flags and environment variables also work as expected. +### Initialization + +The `cosmovisor init ` command creates the folder structure required for using cosmovisor. + +It does the following: + +* creates the `/cosmovisor` folder if it doesn't yet exist +* creates the `/cosmovisor/genesis/bin` folder if it doesn't yet exist +* copies the provided executable file to `/cosmovisor/genesis/bin/` +* creates the `current` link, pointing to the `genesis` folder + +It uses the `DAEMON_HOME` and `DAEMON_NAME` environment variables for folder location and executable name. + +The `cosmovisor init` command is specifically for initializing cosmovisor, and should not be confused with a chain's `init` command (e.g. `cosmovisor run init`). + ### Detecting Upgrades `cosmovisor` is polling the `$DAEMON_HOME/data/upgrade-info.json` file for new upgrade instructions. The file is created by the x/upgrade module in `BeginBlocker` when an upgrade is detected and the blockchain reaches the upgrade height. diff --git a/cosmovisor/cmd/cosmovisor/init.go b/cosmovisor/cmd/cosmovisor/init.go new file mode 100644 index 000000000000..db06caa02297 --- /dev/null +++ b/cosmovisor/cmd/cosmovisor/init.go @@ -0,0 +1,136 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + + "github.com/rs/zerolog" + "github.com/spf13/cobra" + + "github.com/cosmos/cosmos-sdk/cosmovisor" + cverrors "github.com/cosmos/cosmos-sdk/cosmovisor/errors" +) + +func init() { + rootCmd.AddCommand(initCmd) +} + +var initCmd = &cobra.Command{ + Use: "init ", + Short: "Initializes a cosmovisor daemon home directory.", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + logger := cmd.Context().Value(cosmovisor.LoggerKey).(*zerolog.Logger) + + return InitializeCosmovisor(logger, args) + }, +} + +// InitializeCosmovisor initializes the cosmovisor directories, current link, and initial executable. +func InitializeCosmovisor(logger *zerolog.Logger, args []string) error { + if len(args) < 1 || len(args[0]) == 0 { + return errors.New("no provided") + } + pathToExe := args[0] + switch exeInfo, err := os.Stat(pathToExe); { + case os.IsNotExist(err): + return fmt.Errorf("executable file not found: %w", err) + case err != nil: + return fmt.Errorf("could not stat executable: %w", err) + case exeInfo.IsDir(): + return errors.New("invalid path to executable: must not be a directory") + } + cfg, err := getConfigForInitCmd() + if err != nil { + return err + } + + logger.Info().Msg("checking on the genesis/bin directory") + genBinExe := cfg.GenesisBin() + genBinDir, _ := filepath.Split(genBinExe) + genBinDir = filepath.Clean(genBinDir) + switch genBinDirInfo, genBinDirErr := os.Stat(genBinDir); { + case os.IsNotExist(genBinDirErr): + logger.Info().Msgf("creating directory (and any parents): %q", genBinDir) + mkdirErr := os.MkdirAll(genBinDir, 0o755) + if mkdirErr != nil { + return mkdirErr + } + case genBinDirErr != nil: + return fmt.Errorf("error getting info on genesis/bin directory: %w", genBinDirErr) + case !genBinDirInfo.IsDir(): + return fmt.Errorf("the path %q already exists but is not a directory", genBinDir) + default: + logger.Info().Msgf("the %q directory already exists", genBinDir) + } + + logger.Info().Msg("checking on the genesis/bin executable") + if _, err = os.Stat(genBinExe); os.IsNotExist(err) { + logger.Info().Msgf("copying executable into place: %q", genBinExe) + if cpErr := copyFile(pathToExe, genBinExe); cpErr != nil { + return cpErr + } + } else { + logger.Info().Msgf("the %q file already exists", genBinExe) + } + logger.Info().Msgf("making sure %q is executable", genBinExe) + if err = cosmovisor.MarkExecutable(genBinExe); err != nil { + return err + } + if err = cosmovisor.EnsureBinary(genBinExe); err != nil { + return err + } + + logger.Info().Msg("checking on the current symlink and creating it if needed") + cur, curErr := cfg.CurrentBin() + if curErr != nil { + return curErr + } + logger.Info().Msgf("the current symlink points to: %q", cur) + + 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{ + Home: os.Getenv(cosmovisor.EnvHome), + Name: os.Getenv(cosmovisor.EnvName), + } + 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 nil, cverrors.FlattenErrors(errs...) + } + return cfg, nil +} + +// copyFile copies the file at the given source to the given destination. +func copyFile(source, destination string) error { + // assume we already know that src exists and is a regular file. + src, err := os.Open(source) + if err != nil { + return err + } + defer src.Close() + dst, err := os.Create(destination) + if err != nil { + return err + } + defer dst.Close() + _, err = io.Copy(dst, src) + return err +} diff --git a/cosmovisor/cmd/cosmovisor/init_test.go b/cosmovisor/cmd/cosmovisor/init_test.go new file mode 100644 index 000000000000..472936eaac4b --- /dev/null +++ b/cosmovisor/cmd/cosmovisor/init_test.go @@ -0,0 +1,554 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/stretchr/testify/suite" + + "github.com/cosmos/cosmos-sdk/cosmovisor" +) + +type InitTestSuite struct { + suite.Suite +} + +func TestInitTestSuite(t *testing.T) { + suite.Run(t, new(InitTestSuite)) +} + +// cosmovisorInitEnv are some string values of environment variables used to configure Cosmovisor, and used by the init command. +type cosmovisorInitEnv struct { + Home string + Name string +} + +// ToMap creates a map of the cosmovisorInitEnv where the keys are the env var names. +func (c cosmovisorInitEnv) ToMap() map[string]string { + return map[string]string{ + cosmovisor.EnvHome: c.Home, + cosmovisor.EnvName: c.Name, + } +} + +// Set sets the field in this cosmovisorInitEnv corresponding to the provided envVar to the given envVal. +func (c *cosmovisorInitEnv) 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]. Cannot 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 *InitTestSuite) clearEnv() *cosmovisorInitEnv { + s.T().Logf("Clearing environment variables.") + rv := cosmovisorInitEnv{} + 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 *InitTestSuite) setEnv(t *testing.T, env *cosmovisorInitEnv) { + 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) + } + } +} + +var _ io.Reader = BufferedPipe{} +var _ io.Writer = BufferedPipe{} + +// BufferedPipe contains a connected read/write pair of files (a pipe), +// and a buffer of what goes through it that is populated in the background. +type BufferedPipe struct { + // Name is a string to help humans identify this BufferedPipe. + Name string + // Reader is the reader end of the pipe. + Reader *os.File + // Writer is the writer end of the pipe. + Writer *os.File + // BufferReader is the reader used by this BufferedPipe while buffering. + // If this BufferedPipe is not replicating to anything, it will be the same as the Reader. + // Otherwise, it will be a reader encapsulating all desired replication. + BufferReader io.Reader + // Error is the last error encountered by this BufferedPipe. + Error error + + // buffer is the channel used to communicate buffer contents. + buffer chan []byte + // stated is true if this BufferedPipe has been started. + started bool +} + +// NewBufferedPipe creates a new BufferedPipe with the given name. +// Files must be closed once you are done with them (e.g. with .Close()). +// Once ready, buffering must be started using .Start(). See also StartNewBufferedPipe. +func NewBufferedPipe(name string, replicateTo ...io.Writer) (BufferedPipe, error) { + p := BufferedPipe{Name: name} + p.Reader, p.Writer, p.Error = os.Pipe() + if p.Error != nil { + return p, p.Error + } + p.BufferReader = p.Reader + p.AddReplicationTo(replicateTo...) + return p, nil +} + +// StartNewBufferedPipe creates a new BufferedPipe and starts it. +// +// This is functionally equivalent to: +// p, _ := NewBufferedPipe(name, replicateTo...) +// p.Start() +func StartNewBufferedPipe(name string, replicateTo ...io.Writer) (BufferedPipe, error) { + p, err := NewBufferedPipe(name, replicateTo...) + if err != nil { + return p, err + } + p.Start() + return p, nil +} + +// AddReplicationTo adds replication of this buffered pipe to the provided writers. +// +// Panics if this BufferedPipe is already started. +func (p *BufferedPipe) AddReplicationTo(writers ...io.Writer) { + p.panicIfStarted("cannot add further replication") + for _, writer := range writers { + p.BufferReader = io.TeeReader(p.BufferReader, writer) + } +} + +// Start initiates buffering in a background process. +// +// Panics if this BufferedPipe is already started. +func (p *BufferedPipe) Start() { + p.panicIfStarted("cannot restart") + p.buffer = make(chan []byte) + go func() { + var b bytes.Buffer + if _, p.Error = io.Copy(&b, p.BufferReader); p.Error != nil { + b.WriteString("buffer error: " + p.Error.Error()) + } + p.buffer <- b.Bytes() + }() + p.started = true +} + +// IsStarted returns true if this BufferedPipe has already been started. +func (p *BufferedPipe) IsStarted() bool { + return p.started +} + +// IsBuffering returns true if this BufferedPipe has started buffering and has not yet been collected. +func (p *BufferedPipe) IsBuffering() bool { + return p.buffer != nil +} + +// Collect closes this pipe's writer then blocks, returning with the final buffer contents once available. +// If Collect() has previously been called on this BufferedPipe, an empty byte slice is returned. +// +// Panics if this BufferedPipe has not been started. +func (p *BufferedPipe) Collect() []byte { + if !p.started { + panic("buffered pipe " + p.Name + " has not been started: cannot collect") + } + _ = p.Writer.Close() + if p.buffer == nil { + return []byte{} + } + rv := <-p.buffer + p.buffer = nil + return rv +} + +// Read implements the io.Reader interface on this BufferedPipe. +func (p BufferedPipe) Read(bz []byte) (n int, err error) { + return p.Reader.Read(bz) +} + +// Write implements the io.Writer interface on this BufferedPipe. +func (p BufferedPipe) Write(bz []byte) (n int, err error) { + return p.Writer.Write(bz) +} + +// Close makes sure the files in this BufferedPipe are closed. +func (p *BufferedPipe) Close() { + _ = p.Reader.Close() + _ = p.Writer.Close() +} + +// panicIfStarted panics if this BufferedPipe has been started. +func (p *BufferedPipe) panicIfStarted(msg string) { + if p.started { + panic("buffered pipe " + p.Name + " already started: " + msg) + } +} + +// NewCapturingLogger creates a buffered stdout pipe, and a logger that uses it. +func (s *InitTestSuite) NewCapturingLogger() (*BufferedPipe, *zerolog.Logger) { + bufferedStdOut, err := StartNewBufferedPipe("stdout", os.Stdout) + s.Require().NoError(err, "creating stdout buffered pipe") + output := zerolog.ConsoleWriter{Out: bufferedStdOut, TimeFormat: time.RFC3339Nano} + logger := zerolog.New(output).With().Str("module", "cosmovisor").Timestamp().Logger() + return &bufferedStdOut, &logger +} + +// CreateHelloWorld creates a shell script that outputs HELLO WORLD. +// It will have the provided filemode and be in a freshly made temp directory. +// The returned string is the full path to the new file. +func (s *InitTestSuite) CreateHelloWorld(filemode os.FileMode) string { + tmpDir := s.T().TempDir() + tmpExe := filepath.Join(tmpDir, "hello-world.sh") + tmpExeBz := []byte(`#!/bin/sh +echo 'HELLO WORLD' +`) + s.Require().NoError(os.WriteFile(tmpExe, tmpExeBz, filemode)) + return tmpExe +} + +func (s *InitTestSuite) TestInitializeCosmovisorNegativeValidation() { + initEnv := s.clearEnv() + defer s.setEnv(nil, initEnv) + + tmpExe := s.CreateHelloWorld(0o755) + + tmpDir := s.T().TempDir() + + tests := []struct { + name string + env cosmovisorInitEnv + args []string + inErr []string + }{ + { + name: "no args", + env: cosmovisorInitEnv{Home: "/example", Name: "foo"}, + args: []string{}, + inErr: []string{"no provided"}, + }, + { + name: "one empty arg", + env: cosmovisorInitEnv{Home: "/example", Name: "foo"}, + args: []string{""}, + inErr: []string{"no provided"}, + }, + { + name: "exe not found", + env: cosmovisorInitEnv{Home: "/example", Name: "foo"}, + args: []string{filepath.Join(tmpDir, "not-gonna-find-me")}, + inErr: []string{"executable file not found", "not-gonna-find-me"}, + }, + { + name: "exe is a dir", + env: cosmovisorInitEnv{Home: "/example", Name: "foo"}, + args: []string{tmpDir}, + inErr: []string{"invalid path to executable: must not be a directory"}, + }, + { + name: "no name", + env: cosmovisorInitEnv{Home: "/example", Name: ""}, + args: []string{tmpExe}, + inErr: []string{cosmovisor.EnvName + " is not set"}, + }, + { + name: "no home", + env: cosmovisorInitEnv{Home: "", Name: "foo"}, + args: []string{tmpExe}, + inErr: []string{cosmovisor.EnvHome + " is not set"}, + }, + { + name: "home is relative", + env: cosmovisorInitEnv{Home: "./home", Name: "foo"}, + args: []string{tmpExe}, + inErr: []string{cosmovisor.EnvHome + " must be an absolute path"}, + }, + { + name: "no name and no home", + env: cosmovisorInitEnv{Home: "", Name: ""}, + args: []string{tmpExe}, + inErr: []string{cosmovisor.EnvName + " is not set", cosmovisor.EnvHome + " is not set"}, + }, + } + + for _, tc := range tests { + s.T().Run(tc.name, func(t *testing.T) { + s.setEnv(t, &tc.env) + buffer, logger := s.NewCapturingLogger() + err := InitializeCosmovisor(logger, tc.args) + require.Error(t, err) + for _, exp := range tc.inErr { + require.ErrorContains(t, err, exp) + } + // And make sure there wasn't any log output. + // Log output indicates that work is being done despite validation errors. + outputBz := buffer.Collect() + outputStr := string(outputBz) + require.Equal(t, "", outputStr, "log output") + }) + } +} + +func (s *InitTestSuite) TestInitializeCosmovisorInvalidExisting() { + initEnv := s.clearEnv() + defer s.setEnv(nil, initEnv) + + hwExe := s.CreateHelloWorld(0o755) + + s.T().Run("genesis bin is not a directory", func(t *testing.T) { + testDir := t.TempDir() + env := &cosmovisorInitEnv{ + Home: filepath.Join(testDir, "home"), + Name: "pear", + } + genDir := filepath.Join(env.Home, "cosmovisor", "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") + + s.setEnv(t, env) + logger := zerolog.Nop() + expErr := fmt.Sprintf("the path %q already exists but is not a directory", genBin) + err := InitializeCosmovisor(&logger, []string{hwExe}) + require.EqualError(t, err, expErr, "invalid path to executable: must not be a directory", "calling InitializeCosmovisor") + }) + + s.T().Run("the EnsureBinary test fails", func(t *testing.T) { + testDir := t.TempDir() + env := &cosmovisorInitEnv{ + Home: filepath.Join(testDir, "home"), + Name: "grapes", + } + // 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) + 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. + expInLog := []string{ + "checking on the genesis/bin directory", + "checking on the genesis/bin executable", + fmt.Sprintf("the %q file already exists", genBinExe), + fmt.Sprintf("making sure %q is executable", genBinExe), + } + expNotInLog := []string{ + "checking on the current symlink and creating it if needed", + "the current symlink points to", + } + + s.setEnv(t, env) + buffer, logger := s.NewCapturingLogger() + logger.Info().Msgf("Calling InitializeCosmovisor: %s", t.Name()) + err := InitializeCosmovisor(logger, []string{hwExe}) + require.EqualError(t, err, expErr, "calling InitializeCosmovisor") + bufferBz := buffer.Collect() + bufferStr := string(bufferBz) + for _, exp := range expInLog { + assert.Contains(t, bufferStr, exp, "expected log statement") + } + for _, notExp := range expNotInLog { + assert.NotContains(t, bufferStr, notExp, "unexpected log statement") + } + }) + + s.T().Run("current already exists as a file", func(t *testing.T) { + testDir := t.TempDir() + env := &cosmovisorInitEnv{ + Home: filepath.Join(testDir, "home"), + Name: "orange", + } + rootDir := filepath.Join(env.Home, "cosmovisor") + require.NoError(t, os.MkdirAll(rootDir, 0o755)) + curLn := filepath.Join(rootDir, "current") + genDir := filepath.Join(rootDir, "genesis") + require.NoError(t, copyFile(hwExe, curLn)) + expErr := fmt.Sprintf("symlink %s %s: file exists", genDir, curLn) + + s.setEnv(t, env) + buffer, logger := s.NewCapturingLogger() + logger.Info().Msgf("Calling InitializeCosmovisor: %s", t.Name()) + err := InitializeCosmovisor(logger, []string{hwExe}) + require.EqualError(t, err, expErr, "calling InitializeCosmovisor") + bufferBz := buffer.Collect() + bufferStr := string(bufferBz) + assert.Contains(t, bufferStr, "checking on the current symlink and creating it if needed") + }) + + // Failure cases not tested: + // Cannot create genesis bin directory + // I had a test for this that created the `genesis` directory with permissions 0o555. + // I also tried it where it would create the directory at the root of the file system. + // In both cases, the test worked as expected locally, but not on the github runners. So it was removed. + // Given executable is not readable + // I had a test for this that created the executable with permissions 0o311. + // The test worked as expected locally, but not on the github runners. So it was removed. + // Cannot get info on the genesis bin directory. + // Not sure how to create a thing that will return + // an error other than a NotExists error when stat is called on it. + // Cannot write to genesis bin dir + // I had a test for this that created the bin dir with permissions 0o555. + // The test worked as expected locally, but not on the github runners. So it was removed. + // Cannot make the copied file executable. + // Probably need another user for this. + // Create the genesis bin file first, using the other user, and set permissions to 600. +} + +func (s *InitTestSuite) TestInitializeCosmovisorValid() { + initEnv := s.clearEnv() + defer s.setEnv(nil, initEnv) + + hwNonExe := s.CreateHelloWorld(0o644) + hwExe := s.CreateHelloWorld(0o755) + + s.T().Run("starting with blank slate", func(t *testing.T) { + testDir := s.T().TempDir() + env := &cosmovisorInitEnv{ + Home: filepath.Join(testDir, "home"), + Name: "blank", + } + curLn := filepath.Join(env.Home, "cosmovisor", "current") + genBinDir := filepath.Join(env.Home, "cosmovisor", "genesis", "bin") + genBinExe := filepath.Join(genBinDir, env.Name) + expInLog := []string{ + "checking on the genesis/bin directory", + fmt.Sprintf("creating directory (and any parents): %q", genBinDir), + "checking on the genesis/bin executable", + fmt.Sprintf("copying executable into place: %q", genBinExe), + 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), + } + + s.setEnv(s.T(), env) + buffer, logger := s.NewCapturingLogger() + logger.Info().Msgf("Calling InitializeCosmovisor: %s", t.Name()) + err := InitializeCosmovisor(logger, []string{hwNonExe}) + require.NoError(t, err, "calling InitializeCosmovisor") + + _, err = os.Stat(genBinDir) + assert.NoErrorf(t, err, "statting the genesis bin dir: %q", genBinDir) + _, err = os.Stat(curLn) + assert.NoError(t, err, "statting the current link: %q", curLn) + exeInfo, exeErr := os.Stat(genBinExe) + if assert.NoError(t, exeErr, "statting the executable: %q", genBinExe) { + assert.True(t, exeInfo.Mode().IsRegular(), "executable is regular file") + // Check if the world-executable bit is set. + exePermMask := exeInfo.Mode().Perm() & 0o001 + assert.NotEqual(t, 0, exePermMask, "executable mask") + } + bufferBz := buffer.Collect() + bufferStr := string(bufferBz) + for _, exp := range expInLog { + assert.Contains(t, bufferStr, exp) + } + }) + + s.T().Run("genesis and upgrades exist but no current", func(t *testing.T) { + testDir := s.T().TempDir() + env := &cosmovisorInitEnv{ + Home: filepath.Join(testDir, "home"), + Name: "nocur", + } + rootDir := filepath.Join(env.Home, "cosmovisor") + genBinDir := filepath.Join(rootDir, "genesis", "bin") + genBinDirExe := filepath.Join(genBinDir, env.Name) + require.NoError(t, os.MkdirAll(genBinDir, 0o755), "making genesis bin dir") + require.NoError(t, copyFile(hwExe, genBinDirExe), "copying executable to genesis") + upgradesDir := filepath.Join(rootDir, "upgrades") + for i := 1; i <= 5; i++ { + upgradeBinDir := filepath.Join(upgradesDir, fmt.Sprintf("upgrade-%02d", i), "bin") + upgradeBinDirExe := filepath.Join(upgradeBinDir, env.Name) + require.NoErrorf(t, os.MkdirAll(upgradeBinDir, 0o755), "Making upgrade %d bin dir", i) + require.NoErrorf(t, copyFile(hwExe, upgradeBinDirExe), "copying executable to upgrade %d", i) + } + + expInLog := []string{ + "checking on the genesis/bin directory", + fmt.Sprintf("the %q directory already exists", genBinDir), + "checking on the genesis/bin executable", + 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), + } + + s.setEnv(t, env) + buffer, logger := s.NewCapturingLogger() + logger.Info().Msgf("Calling InitializeCosmovisor: %s", t.Name()) + err := InitializeCosmovisor(logger, []string{hwExe}) + require.NoError(t, err, "calling InitializeCosmovisor") + bufferBz := buffer.Collect() + bufferStr := string(bufferBz) + for _, exp := range expInLog { + assert.Contains(t, bufferStr, exp) + } + }) + + s.T().Run("genesis bin dir exists empty", func(t *testing.T) { + testDir := s.T().TempDir() + env := &cosmovisorInitEnv{ + Home: filepath.Join(testDir, "home"), + Name: "emptygen", + } + rootDir := filepath.Join(env.Home, "cosmovisor") + genBinDir := filepath.Join(rootDir, "genesis", "bin") + genBinExe := filepath.Join(genBinDir, env.Name) + require.NoError(t, os.MkdirAll(genBinDir, 0o755), "making genesis bin dir") + + expInLog := []string{ + "checking on the genesis/bin directory", + fmt.Sprintf("the %q directory already exists", genBinDir), + "checking on the genesis/bin executable", + 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), + } + + s.setEnv(t, env) + buffer, logger := s.NewCapturingLogger() + logger.Info().Msgf("Calling InitializeCosmovisor: %s", t.Name()) + err := InitializeCosmovisor(logger, []string{hwExe}) + require.NoError(t, err, "calling InitializeCosmovisor") + bufferBz := buffer.Collect() + bufferStr := string(bufferBz) + for _, exp := range expInLog { + assert.Contains(t, bufferStr, exp) + } + }) +}