cosmos-sdk/x/upgrade/plan/downloader_test.go

300 lines
10 KiB
Go
Raw Normal View History

feat: Add upgrade proposal plan validation to CLI (#10379) <!-- The default pull request template is for types feat, fix, or refactor. For other templates, add one of the following parameters to the url: - template=docs.md - template=other.md --> ## Description Closes: #10286 When submitting a software upgrade proposal (e.g. `$DAEMON tx gov submit-proposal software-upgrade`) * Validate the plan info by default. * Add flag `--no-validate` to allow skipping that validation. * Add flag `--daemon-name` to designate the executable name (needed for validation). * The daemon name comes first from the `--daemon-name` flag. If that's not provided, it looks for a `DAEMON_NAME` environment variable (to match what's used by Cosmovisor). If that's not set, the name of the currently running executable is used. Things that are validated: * The plan info cannot be empty or blank. * If the plan info is a url: * It must have a `checksum` query parameter. * It must return properly formatted plan info JSON. * The `checksum` is correct. * If the plan info is not a url: * It must be propery formatted plan info JSON. * There is at least one entry in the `binaries` field. * The keys of the `binaries` field are either "any" or in the format of "os/arch". * All URLs contain a `checksum` query parameter. * Each URL contains a usable response. * The `checksum` is correct for each URL. Note: With this change, either a valid `--upgrade-info` will need to be provided, or else `--no-validate` must be provided. If no `--upgrade-info` is given, a validation error is returned. --- ### 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/master/CONTRIBUTING.md#pr-targeting)) - [x] provided a link to the relevant issue or specification - [x] followed the guidelines for [building modules](https://github.com/cosmos/cosmos-sdk/blob/master/docs/building-modules) - [x] included the necessary unit and integration [tests](https://github.com/cosmos/cosmos-sdk/blob/master/CONTRIBUTING.md#testing) - [x] added a changelog entry to `CHANGELOG.md` - [x] included comments for [documenting Go code](https://blog.golang.org/godoc) - [ ] updated the relevant documentation or specification - [ ] reviewed "Files changed" and left comments if necessary - [x] 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)
2021-11-12 09:44:33 -08:00
package plan
import (
"archive/zip"
"crypto/sha256"
"fmt"
"io"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/stretchr/testify/suite"
)
type DownloaderTestSuite struct {
suite.Suite
// Home is a temporary directory for use in these tests.
// It will have a src/ for things to download.
Home string
}
func (s *DownloaderTestSuite) SetupTest() {
s.Home = s.T().TempDir()
s.Assert().NoError(os.MkdirAll(filepath.Join(s.Home, "src"), 0777), "creating src/ dir")
s.T().Logf("Home: [%s]", s.Home)
}
func TestDownloaderTestSuite(t *testing.T) {
suite.Run(t, new(DownloaderTestSuite))
}
// TestFile represents a file that will be used for a test.
type TestFile struct {
// Name is the relative path and name of the file.
Name string
// Contents is the contents of the file.
Contents []byte
}
func NewTestFile(name, contents string) *TestFile {
return &TestFile{
Name: name,
Contents: []byte(contents),
}
}
// SaveIn saves this TestFile in the given path.
// The full path to the file is returned.
func (f TestFile) SaveIn(path string) (string, error) {
name := filepath.Join(path, f.Name)
file, err := os.Create(name)
if err != nil {
return name, err
}
defer file.Close()
_, err = file.Write(f.Contents)
return name, err
}
// TestZip represents a collection of TestFile objects to be zipped into an archive.
type TestZip []*TestFile
func NewTestZip(testFiles ...*TestFile) TestZip {
tz := make([]*TestFile, len(testFiles))
for i, tf := range testFiles {
tz[i] = tf
}
return tz
}
// SaveAs saves this TestZip at the given path.
func (z TestZip) SaveAs(path string) error {
archive, err := os.Create(path)
if err != nil {
return err
}
defer archive.Close()
zipper := zip.NewWriter(archive)
for _, tf := range z {
zfw, zfwerr := zipper.Create(tf.Name)
if zfwerr != nil {
return zfwerr
}
_, err = zfw.Write(tf.Contents)
if err != nil {
return err
}
}
return zipper.Close()
}
// saveTestZip saves a TestZip in this test's Home/src directory with the given name.
// The full path to the saved archive is returned.
func (s DownloaderTestSuite) saveSrcTestZip(name string, z TestZip) string {
fullName := filepath.Join(s.Home, "src", name)
s.Require().NoError(z.SaveAs(fullName), "saving test zip %s", name)
return fullName
}
// saveSrcTestFile saves a TestFile in this test's Home/src directory.
// The full path to the saved file is returned.
func (s DownloaderTestSuite) saveSrcTestFile(f *TestFile) string {
path := filepath.Join(s.Home, "src")
fullName, err := f.SaveIn(path)
s.Require().NoError(err, "saving test file %s", f.Name)
return fullName
}
// requireFileExistsAndIsExecutable requires that the given file exists and is executable.
func requireFileExistsAndIsExecutable(t *testing.T, path string) {
info, err := os.Stat(path)
require.NoError(t, err, "stat error")
perm := info.Mode().Perm()
// Checks if at least one executable bit is set (user, group, or other)
isExe := perm&0111 != 0
require.True(t, isExe, "is executable: permissions = %s", perm)
}
// requireFileEquals requires that the contents of the file at the given path
// is equal to the contents of the given TestFile.
func requireFileEquals(t *testing.T, path string, tf *TestFile) {
file, err := os.ReadFile(path)
require.NoError(t, err, "reading file")
require.Equal(t, string(tf.Contents), string(file), "file contents")
}
// makeFileUrl converts the given path to a URL with the correct checksum query parameter.
func makeFileURL(t *testing.T, path string) string {
f, err := os.Open(path)
require.NoError(t, err, "opening file")
defer f.Close()
hasher := sha256.New()
_, err = io.Copy(hasher, f)
require.NoError(t, err, "copying file to hasher")
return fmt.Sprintf("file://%s?checksum=sha256:%x", path, hasher.Sum(nil))
}
func (s *DownloaderTestSuite) TestDownloadUpgrade() {
justAFile := NewTestFile("just-a-file", "#!/usr/bin\necho 'I am just a file'\n")
someFileName := "some-file"
someFileInBin := NewTestFile("bin"+someFileName, "#!/usr/bin\necho 'I am some file in bin'\n")
anotherFile := NewTestFile("another-file", "#!/usr/bin\necho 'I am just another file'\n")
justAFilePath := s.saveSrcTestFile(justAFile)
justAFileZip := s.saveSrcTestZip(justAFile.Name+".zip", NewTestZip(justAFile))
someFileInBinZip := s.saveSrcTestZip(someFileInBin.Name+".zip", NewTestZip(someFileInBin))
allFilesZip := s.saveSrcTestZip(anotherFile.Name+".zip", NewTestZip(justAFile, someFileInBin, anotherFile))
getDstDir := func(testName string) string {
_, tName := filepath.Split(testName)
return s.Home + "/dst/" + tName
}
s.T().Run("url does not exist", func(t *testing.T) {
dstRoot := getDstDir(t.Name())
url := "file:///never/gonna/be/a/thing.zip?checksum=sha256:2c22e34510bd1d4ad2343cdc54f7165bccf30caef73f39af7dd1db2795a3da48"
err := DownloadUpgrade(dstRoot, url, "nothing")
require.Error(t, err)
assert.Contains(t, err.Error(), "no such file or directory")
})
s.T().Run("url does not have checksum", func(t *testing.T) {
dstRoot := getDstDir(t.Name())
url := "file://" + justAFilePath
err := DownloadUpgrade(dstRoot, url, justAFile.Name)
require.Error(t, err)
require.Contains(t, err.Error(), "missing checksum query parameter")
})
s.T().Run("url has incorrect checksum", func(t *testing.T) {
dstRoot := getDstDir(t.Name())
badChecksum := "2c22e34510bd1d4ad2343cdc54f7165bccf30caef73f39af7dd1db2795a3da48"
url := "file://" + justAFilePath + "?checksum=sha256:" + badChecksum
err := DownloadUpgrade(dstRoot, url, justAFile.Name)
require.Error(t, err)
assert.Contains(t, err.Error(), "Checksums did not match")
assert.Contains(t, err.Error(), "Expected: "+badChecksum)
})
s.T().Run("url returns single file", func(t *testing.T) {
dstRoot := getDstDir(t.Name())
url := makeFileURL(t, justAFilePath)
err := DownloadUpgrade(dstRoot, url, justAFile.Name)
require.NoError(t, err)
expectedFile := filepath.Join(dstRoot, "bin", justAFile.Name)
requireFileExistsAndIsExecutable(t, expectedFile)
requireFileEquals(t, expectedFile, justAFile)
})
s.T().Run("url returns archive with file in bin", func(t *testing.T) {
dstRoot := getDstDir(t.Name())
url := makeFileURL(t, someFileInBinZip)
err := DownloadUpgrade(dstRoot, url, someFileName)
require.NoError(t, err)
expectedFile := filepath.Join(dstRoot, "bin", someFileName)
requireFileExistsAndIsExecutable(t, expectedFile)
requireFileEquals(t, expectedFile, someFileInBin)
})
s.T().Run("url returns archive with just expected file", func(t *testing.T) {
dstRoot := getDstDir(t.Name())
url := makeFileURL(t, justAFileZip)
err := DownloadUpgrade(dstRoot, url, justAFile.Name)
require.NoError(t, err)
expectedFile := filepath.Join(dstRoot, "bin", justAFile.Name)
requireFileExistsAndIsExecutable(t, expectedFile)
requireFileEquals(t, expectedFile, justAFile)
})
s.T().Run("url returns archive without expected file", func(t *testing.T) {
dstRoot := getDstDir(t.Name())
url := makeFileURL(t, allFilesZip)
err := DownloadUpgrade(dstRoot, url, "not-expected")
require.Error(t, err)
require.Contains(t, err.Error(), "result does not contain a bin/not-expected or not-expected file")
})
}
func (s *DownloaderTestSuite) TestEnsureBinary() {
nonExeName := s.saveSrcTestFile(NewTestFile("non-exe.txt", "Not executable"))
s.Require().NoError(os.Chmod(nonExeName, 0600), "chmod error nonExeName")
isExeName := s.saveSrcTestFile(NewTestFile("is-exe.sh", "#!/bin/bash\necho 'executing'\n"))
s.Require().NoError(os.Chmod(isExeName, 0777), "chmod error isExeName")
s.T().Run("file does not exist", func(t *testing.T) {
name := filepath.Join(s.Home, "does-not-exist.txt")
actual := EnsureBinary(name)
require.Error(t, actual)
})
s.T().Run("file is a directory", func(t *testing.T) {
name := filepath.Join(s.Home, "src")
actual := EnsureBinary(name)
require.EqualError(t, actual, fmt.Sprintf("%s is not a regular file", "src"))
})
s.T().Run("file exists and becomes executable", func(t *testing.T) {
name := nonExeName
actual := EnsureBinary(name)
require.NoError(t, actual, "EnsureBinary error")
requireFileExistsAndIsExecutable(t, name)
})
s.T().Run("file is already executable", func(t *testing.T) {
name := isExeName
actual := EnsureBinary(name)
require.NoError(t, actual, "EnsureBinary error")
requireFileExistsAndIsExecutable(t, name)
})
}
func (s *DownloaderTestSuite) TestDownloadURLWithChecksum() {
planContents := `{"binaries":{"xxx/yyy":"url"}}`
planFile := NewTestFile("plan-info.json", planContents)
planPath := s.saveSrcTestFile(planFile)
planChecksum := fmt.Sprintf("%x", sha256.Sum256(planFile.Contents))
emptyFile := NewTestFile("empty-plan-info.json", "")
emptyPlanPath := s.saveSrcTestFile(emptyFile)
emptyChecksum := fmt.Sprintf("%x", sha256.Sum256(emptyFile.Contents))
s.T().Run("url does not exist", func(t *testing.T) {
url := "file:///never-gonna-be-a-thing?checksum=sha256:2c22e34510bd1d4ad2343cdc54f7165bccf30caef73f39af7dd1db2795a3da48"
_, err := DownloadURLWithChecksum(url)
require.Error(t, err)
assert.Contains(t, err.Error(), "could not download url")
})
s.T().Run("without checksum", func(t *testing.T) {
url := "file://" + planPath
_, err := DownloadURLWithChecksum(url)
require.Error(t, err)
assert.Contains(t, err.Error(), "missing checksum query parameter")
})
s.T().Run("with correct checksum", func(t *testing.T) {
url := "file://" + planPath + "?checksum=sha256:" + planChecksum
actual, err := DownloadURLWithChecksum(url)
require.NoError(t, err)
require.Equal(t, planContents, actual)
})
s.T().Run("with incorrect checksum", func(t *testing.T) {
badChecksum := "2c22e34510bd1d4ad2343cdc54f7165bccf30caef73f39af7dd1db2795a3da48"
url := "file://" + planPath + "?checksum=sha256:" + badChecksum
_, err := DownloadURLWithChecksum(url)
require.Error(t, err)
assert.Contains(t, err.Error(), "Checksums did not match")
assert.Contains(t, err.Error(), "Expected: "+badChecksum)
assert.Contains(t, err.Error(), "Got: "+planChecksum)
})
s.T().Run("plan is empty", func(t *testing.T) {
url := "file://" + emptyPlanPath + "?checksum=sha256:" + emptyChecksum
_, err := DownloadURLWithChecksum(url)
require.Error(t, err)
assert.Contains(t, err.Error(), "no content returned")
})
}