From f952afbfe875601153526b330e9858639e6010ba Mon Sep 17 00:00:00 2001 From: piux2 <90544084+piux2@users.noreply.github.com> Date: Tue, 3 Oct 2023 18:41:34 -0700 Subject: [PATCH 1/2] feature: unsafe reset all --- gno.land/cmd/gnoland/README.md | 13 +++ gno.land/cmd/gnoland/main.go | 15 ++++ gno.land/cmd/gnoland/mockio.go | 97 +++++++++++++++++++++ gno.land/cmd/gnoland/reset.go | 134 +++++++++++++++++++++++++++++ gno.land/cmd/gnoland/reset_test.go | 68 +++++++++++++++ gno.land/cmd/gnoland/root.go | 53 ++++++++---- gno.land/cmd/gnoland/start.go | 41 +++------ gno.land/cmd/gnoland/start_test.go | 30 ++++--- 8 files changed, 388 insertions(+), 63 deletions(-) create mode 100644 gno.land/cmd/gnoland/main.go create mode 100644 gno.land/cmd/gnoland/mockio.go create mode 100644 gno.land/cmd/gnoland/reset.go create mode 100644 gno.land/cmd/gnoland/reset_test.go diff --git a/gno.land/cmd/gnoland/README.md b/gno.land/cmd/gnoland/README.md index 5132cd7f52b..51fa1076524 100644 --- a/gno.land/cmd/gnoland/README.md +++ b/gno.land/cmd/gnoland/README.md @@ -11,3 +11,16 @@ $> gnoland start Afterward, you can interact with [`gnokey`](../gnokey) or launch a [`gnoweb`](../gnoweb) interface. + + +## Reset `gnoland` node back to genesis state. It's only suitable for testnets. + + $> gnoland unsafe-reset-all + +It removes the database and validator state but leaves the genesis.json and config.toml files unchanged. + +The `unsafe-reset-all` command is labeled "unsafe" because: + +1. It irreversibly deletes all node data, risking data loss. +2. It may lead to double signing or chain forks in production +3. It resets the `priv_validator_state.json`, and can cause network disruption if uncoordinated. diff --git a/gno.land/cmd/gnoland/main.go b/gno.land/cmd/gnoland/main.go new file mode 100644 index 00000000000..5310046c18a --- /dev/null +++ b/gno.land/cmd/gnoland/main.go @@ -0,0 +1,15 @@ +package main + +import ( + "context" + "fmt" + "os" +) + +func main() { + rootCmd := newRootCmd() + if err := rootCmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil { + _, _ = fmt.Fprintf(os.Stderr, "%+v\n", err) + os.Exit(1) + } +} diff --git a/gno.land/cmd/gnoland/mockio.go b/gno.land/cmd/gnoland/mockio.go new file mode 100644 index 00000000000..1c4401a6af6 --- /dev/null +++ b/gno.land/cmd/gnoland/mockio.go @@ -0,0 +1,97 @@ +package main + +import ( + "bytes" + "fmt" + "io" + "log" + "os" +) + +// This is for testing purpose only. We don't need to or should not use IOpipe in normal flow. +// We redirect os.Stdin for mocking tests so that we don't need to pass commands.IO, which +// included an os.Stdin, to all the server command. It is not safe to expose os.Stdin in the blockchain node + +type MockStdin struct { + origStdout *os.File + stdoutReader *os.File + + outCh chan []byte + + origStdin *os.File + stdinWriter *os.File +} + +func NewMockStdin(input string) (*MockStdin, error) { + // Pipe for stdin. w ( stdinWriter ) -> r (stdin) + stdinReader, stdinWriter, err := os.Pipe() + if err != nil { + return nil, err + } + + // Pipe for stdout. w( stdout ) -> r (stdoutReader) + stdoutReader, stdoutWriter, err := os.Pipe() + if err != nil { + return nil, err + } + + origStdin := os.Stdin + os.Stdin = stdinReader + + _, err = stdinWriter.Write([]byte(input)) + if err != nil { + stdinWriter.Close() + os.Stdin = origStdin + return nil, err + } + + origStdout := os.Stdout + os.Stdout = stdoutWriter + + outCh := make(chan []byte) + + // This goroutine reads stdout into a buffer in the background. + go func() { + var b bytes.Buffer + if _, err := io.Copy(&b, stdoutReader); err != nil { + log.Println(err) + } + outCh <- b.Bytes() + }() + + return &MockStdin{ + origStdout: origStdout, + stdoutReader: stdoutReader, + outCh: outCh, + origStdin: origStdin, + stdinWriter: stdinWriter, + }, nil +} + +// ReadAndRestore collects all captured stdout and returns it; it also restores +// os.Stdin and os.Stdout to their original values. +func (i *MockStdin) ReadAndClose() ([]byte, error) { + if i.stdoutReader == nil { + return nil, fmt.Errorf("ReadAndRestore from closed FakeStdio") + } + + // Close the writer side of the faked stdout pipe. This signals to the + // background goroutine that it should exit. + os.Stdout.Close() + out := <-i.outCh + + os.Stdout = i.origStdout + os.Stdin = i.origStdin + + if i.stdoutReader != nil { + i.stdoutReader.Close() + i.stdoutReader = nil + } + + if i.stdinWriter != nil { + i.stdinWriter.Close() + i.stdinWriter = nil + } + + return out, nil +} diff --git a/gno.land/cmd/gnoland/reset.go b/gno.land/cmd/gnoland/reset.go new file mode 100644 index 00000000000..6b7a2416438 --- /dev/null +++ b/gno.land/cmd/gnoland/reset.go @@ -0,0 +1,134 @@ +package main + +import ( + "context" + "flag" + "os" + "path/filepath" + + "github.com/gnolang/gno/tm2/pkg/bft/privval" + "github.com/gnolang/gno/tm2/pkg/commands" + + "github.com/gnolang/gno/tm2/pkg/log" + osm "github.com/gnolang/gno/tm2/pkg/os" +) + +type resetCfg struct { + baseCfg +} + +func (rc *resetCfg) RegisterFlags(fs *flag.FlagSet) {} + +// XXX: this is totally unsafe. +// it's only suitable for testnets. +// It could result in data loss and network disrutpion while running the node and without coordination +func newResetAllCmd(bc baseCfg) *commands.Command { + cfg := resetCfg{ + baseCfg: bc, + } + + return commands.NewCommand( + commands.Metadata{ + Name: "unsafe-reset-all", + ShortUsage: "unsafe-reset-all", + ShortHelp: "(unsafe) Remove all the data and WAL, reset this node's validator to genesis state", + }, + &cfg, + func(_ context.Context, args []string) error { + return execResetAll(cfg, args) + }, + ) +} + +func execResetAll(rc resetCfg, args []string) (err error) { + config := rc.tmConfig + + return resetAll( + config.DBDir(), + config.PrivValidatorKeyFile(), + config.PrivValidatorStateFile(), + logger, + ) +} + +// resetAll removes address book files plus all data, and resets the privValdiator data. +func resetAll(dbDir, privValKeyFile, privValStateFile string, logger log.Logger) error { + if err := os.RemoveAll(dbDir); err == nil { + logger.Info("Removed all blockchain history", "dir", dbDir) + } else { + logger.Error("Error removing all blockchain history", "dir", dbDir, "err", err) + } + + if err := osm.EnsureDir(dbDir, 0o700); err != nil { + logger.Error("unable to recreate dbDir", "err", err) + } + + // recreate the dbDir since the privVal state needs to live there + resetFilePV(privValKeyFile, privValStateFile, logger) + return nil +} + +// resetState removes address book files plus all databases. +func resetState(dbDir string, logger log.Logger) error { + blockdb := filepath.Join(dbDir, "blockstore.db") + state := filepath.Join(dbDir, "state.db") + wal := filepath.Join(dbDir, "cs.wal") + gnolang := filepath.Join(dbDir, "gnolang.db") + + if osm.FileExists(blockdb) { + if err := os.RemoveAll(blockdb); err == nil { + logger.Info("Removed all blockstore.db", "dir", blockdb) + } else { + logger.Error("error removing all blockstore.db", "dir", blockdb, "err", err) + } + } + + if osm.FileExists(state) { + if err := os.RemoveAll(state); err == nil { + logger.Info("Removed all state.db", "dir", state) + } else { + logger.Error("error removing all state.db", "dir", state, "err", err) + } + } + + if osm.FileExists(wal) { + if err := os.RemoveAll(wal); err == nil { + logger.Info("Removed all cs.wal", "dir", wal) + } else { + logger.Error("error removing all cs.wal", "dir", wal, "err", err) + } + } + + if osm.FileExists(gnolang) { + if err := os.RemoveAll(gnolang); err == nil { + logger.Info("Removed all gnolang.db", "dir", gnolang) + } else { + logger.Error("error removing all gnolang.db", "dir", gnolang, "err", err) + } + } + + if err := osm.EnsureDir(dbDir, 0o700); err != nil { + logger.Error("unable to recreate dbDir", "err", err) + } + return nil +} + +func resetFilePV(privValKeyFile, privValStateFile string, logger log.Logger) { + if _, err := os.Stat(privValKeyFile); err == nil { + pv := privval.LoadFilePVEmptyState(privValKeyFile, privValStateFile) + pv.Reset() + logger.Info( + "Reset private validator file to genesis state", + "keyFile", privValKeyFile, + "stateFile", privValStateFile, + ) + } else { + pv := privval.GenFilePV(privValKeyFile, privValStateFile) + pv.Save() + logger.Info( + "Generated private validator file", + "keyFile", privValKeyFile, + "stateFile", privValStateFile, + ) + } +} diff --git a/gno.land/cmd/gnoland/reset_test.go b/gno.land/cmd/gnoland/reset_test.go new file mode 100644 index 00000000000..ec4d63f5250 --- /dev/null +++ b/gno.land/cmd/gnoland/reset_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "path/filepath" + "testing" + + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + tmtime "github.com/gnolang/gno/tm2/pkg/bft/types/time" + "github.com/stretchr/testify/require" + + cfg "github.com/gnolang/gno/tm2/pkg/bft/config" + "github.com/gnolang/gno/tm2/pkg/bft/privval" + "github.com/gnolang/gno/tm2/pkg/p2p" +) + +func TestResetAll(t *testing.T) { + config := cfg.TestConfig() + dir := t.TempDir() + config.SetRootDir(dir) + config.EnsureDirs() + + require.NoError(t, initFilesWithConfig(config)) + pv := privval.LoadFilePV(config.PrivValidatorKeyFile(), config.PrivValidatorStateFile()) + pv.LastSignState.Height = 10 + pv.Save() + + require.NoError(t, resetAll(config.DBDir(), config.PrivValidatorKeyFile(), + config.PrivValidatorStateFile(), logger)) + + require.DirExists(t, config.DBDir()) + require.NoFileExists(t, filepath.Join(config.DBDir(), "block.db")) + require.NoFileExists(t, filepath.Join(config.DBDir(), "state.db")) + require.NoFileExists(t, filepath.Join(config.DBDir(), "gnolang.db")) + require.FileExists(t, config.PrivValidatorStateFile()) + require.FileExists(t, config.GenesisFile()) + pv = privval.LoadFilePV(config.PrivValidatorKeyFile(), config.PrivValidatorStateFile()) + require.Equal(t, int64(0), pv.LastSignState.Height) +} + +func initFilesWithConfig(config *cfg.Config) error { + // private validator + privValKeyFile := config.PrivValidatorKeyFile() + privValStateFile := config.PrivValidatorStateFile() + var pv *privval.FilePV + pv = privval.GenFilePV(privValKeyFile, privValStateFile) + pv.Save() + nodeKeyFile := config.NodeKeyFile() + if _, err := p2p.LoadOrGenNodeKey(nodeKeyFile); err != nil { + return err + } + + genFile := config.GenesisFile() + genDoc := bft.GenesisDoc{ + ChainID: "test-chain-%v", + GenesisTime: tmtime.Now(), + ConsensusParams: bft.DefaultConsensusParams(), + } + key := pv.GetPubKey() + genDoc.Validators = []bft.GenesisValidator{{ + Address: key.Address(), + PubKey: key, + Power: 10, + }} + if err := genDoc.SaveAs(genFile); err != nil { + return err + } + return nil +} diff --git a/gno.land/cmd/gnoland/root.go b/gno.land/cmd/gnoland/root.go index cf2a6252478..aaf1e5b16d3 100644 --- a/gno.land/cmd/gnoland/root.go +++ b/gno.land/cmd/gnoland/root.go @@ -1,42 +1,57 @@ package main import ( - "context" - "fmt" + // "context" + + "flag" "os" + tmcfg "github.com/gnolang/gno/tm2/pkg/bft/config" "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/peterbourgon/ff/v3" - "github.com/peterbourgon/ff/v3/fftoml" + "github.com/gnolang/gno/tm2/pkg/log" ) -func main() { - io := commands.NewDefaultIO() - cmd := newRootCmd(io) +var logger = log.NewTMLogger(log.NewSyncWriter(os.Stdout)) + +type baseCfg struct { + rootDir string + tmConfig tmcfg.Config +} - if err := cmd.ParseAndRun(context.Background(), os.Args[1:]); err != nil { - _, _ = fmt.Fprintf(os.Stderr, "%+v\n", err) - os.Exit(1) - } +func (bc *baseCfg) RegisterFlags(fs *flag.FlagSet) { + fs.StringVar( + &bc.rootDir, + "root-dir", + "testdir", + "directory for config and data", + ) } -func newRootCmd(io *commands.IO) *commands.Command { +func newRootCmd() *commands.Command { + bc := baseCfg{} + cmd := commands.NewCommand( commands.Metadata{ ShortUsage: " [flags] [...]", - ShortHelp: "Starts the gnoland blockchain node", - Options: []ff.Option{ - ff.WithConfigFileFlag("config"), - ff.WithConfigFileParser(fftoml.Parser), - }, + ShortHelp: "The gnoland blockchain node", }, - commands.NewEmptyConfig(), + &bc, commands.HelpExec, ) + initTmConfig(&bc) cmd.AddSubCommands( - newStartCmd(io), + newStartCmd(bc), + newResetAllCmd(bc), ) return cmd } + +// we relies on the flag option to pass in the root directory before we can identify where +func initTmConfig(bc *baseCfg) error { + bc.tmConfig = *tmcfg.LoadOrMakeConfigWithOptions(bc.rootDir, func(cfg *tmcfg.Config) { + }) + + return nil +} diff --git a/gno.land/cmd/gnoland/start.go b/gno.land/cmd/gnoland/start.go index b2134d86ea9..2877f246d91 100644 --- a/gno.land/cmd/gnoland/start.go +++ b/gno.land/cmd/gnoland/start.go @@ -14,31 +14,31 @@ import ( "github.com/gnolang/gno/gnovm/pkg/gnomod" "github.com/gnolang/gno/tm2/pkg/amino" abci "github.com/gnolang/gno/tm2/pkg/bft/abci/types" - "github.com/gnolang/gno/tm2/pkg/bft/config" "github.com/gnolang/gno/tm2/pkg/bft/node" "github.com/gnolang/gno/tm2/pkg/bft/privval" bft "github.com/gnolang/gno/tm2/pkg/bft/types" "github.com/gnolang/gno/tm2/pkg/commands" "github.com/gnolang/gno/tm2/pkg/crypto" - "github.com/gnolang/gno/tm2/pkg/log" osm "github.com/gnolang/gno/tm2/pkg/os" "github.com/gnolang/gno/tm2/pkg/std" ) type startCfg struct { + baseCfg skipFailingGenesisTxs bool skipStart bool genesisBalancesFile string genesisTxsFile string chainID string genesisRemote string - rootDir string genesisMaxVMCycles int64 config string } -func newStartCmd(io *commands.IO) *commands.Command { - cfg := &startCfg{} +func newStartCmd(bc baseCfg) *commands.Command { + cfg := startCfg{ + baseCfg: bc, + } return commands.NewCommand( commands.Metadata{ @@ -46,9 +46,9 @@ func newStartCmd(io *commands.IO) *commands.Command { ShortUsage: "start [flags]", ShortHelp: "Run the full node", }, - cfg, + &cfg, func(_ context.Context, args []string) error { - return execStart(cfg, args, io) + return execStart(cfg, args) }, ) } @@ -89,13 +89,6 @@ func (c *startCfg) RegisterFlags(fs *flag.FlagSet) { "the ID of the chain", ) - fs.StringVar( - &c.rootDir, - "root-dir", - "testdir", - "directory for config and data", - ) - fs.StringVar( &c.genesisRemote, "genesis-remote", @@ -109,23 +102,11 @@ func (c *startCfg) RegisterFlags(fs *flag.FlagSet) { 10_000_000, "set maximum allowed vm cycles per operation. Zero means no limit.", ) - - fs.StringVar( - &c.config, - "config", - "", - "config file (optional)", - ) } -func execStart(c *startCfg, args []string, io *commands.IO) error { - logger := log.NewTMLogger(log.NewSyncWriter(io.Out)) +func execStart(c startCfg, args []string) error { rootDir := c.rootDir - - cfg := config.LoadOrMakeConfigWithOptions(rootDir, func(cfg *config.Config) { - cfg.Consensus.CreateEmptyBlocks = true - cfg.Consensus.CreateEmptyBlocksInterval = 0 * time.Second - }) + cfg := &c.baseCfg.tmConfig // create priv validator first. // need it to generate genesis.json @@ -158,10 +139,10 @@ func execStart(c *startCfg, args []string, io *commands.IO) error { return fmt.Errorf("error in creating node: %w", err) } - fmt.Fprintln(io.Err, "Node created.") + fmt.Println("Node created.") if c.skipStart { - fmt.Fprintln(io.Err, "'--skip-start' is set. Exiting.") + fmt.Println("'--skip-start' is set. Exiting.") return nil } diff --git a/gno.land/cmd/gnoland/start_test.go b/gno.land/cmd/gnoland/start_test.go index 27ef2f572ea..aa734d11db0 100644 --- a/gno.land/cmd/gnoland/start_test.go +++ b/gno.land/cmd/gnoland/start_test.go @@ -1,14 +1,12 @@ package main import ( - "bytes" "context" "os" "path/filepath" "strings" "testing" - "github.com/gnolang/gno/tm2/pkg/commands" "github.com/stretchr/testify/require" ) @@ -24,24 +22,28 @@ func TestStartInitialize(t *testing.T) { for _, tc := range cases { name := strings.Join(tc.args, " ") - t.Run(name, func(t *testing.T) { - mockOut := bytes.NewBufferString("") - mockErr := bytes.NewBufferString("") - io := commands.NewTestIO() - io.SetOut(commands.WriteNopCloser(mockOut)) - io.SetErr(commands.WriteNopCloser(mockErr)) - cmd := newRootCmd(io) + in, err := NewMockStdin(name) + if err != nil { + t.Fatal("failed creating test io pipe") + } + t.Run(name, func(t *testing.T) { + cmd := newRootCmd() t.Logf(`Running "gnoland %s"`, strings.Join(tc.args, " ")) err := cmd.ParseAndRun(context.Background(), tc.args) require.NoError(t, err) - stdout := mockOut.String() - stderr := mockErr.String() + bz, err := in.ReadAndClose() + if err != nil { + t.Fatal("failed reading test io pipe") + } + + out := string(bz) + + require.Contains(t, out, "Node created.", "failed to create node") - require.Contains(t, stderr, "Node created.", "failed to create node") - require.Contains(t, stderr, "'--skip-start' is set. Exiting.", "not exited with skip-start") - require.NotContains(t, stdout, "panic:") + require.Contains(t, out, "'--skip-start' is set. Exiting.", "not exited with skip-start") + require.NotContains(t, out, "panic:") }) } } From 66e023fcc92aa16116b71fd3ebcc904e5726ad45 Mon Sep 17 00:00:00 2001 From: piux2 <90544084+piux2@users.noreply.github.com> Date: Wed, 4 Oct 2023 15:14:04 -0700 Subject: [PATCH 2/2] add seperate method --- gno.land/cmd/gnoland/mockio.go | 25 ++++++++++++++++++++++--- gno.land/cmd/gnoland/start_test.go | 1 + 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/gno.land/cmd/gnoland/mockio.go b/gno.land/cmd/gnoland/mockio.go index 1c4401a6af6..3ebb6205197 100644 --- a/gno.land/cmd/gnoland/mockio.go +++ b/gno.land/cmd/gnoland/mockio.go @@ -8,9 +8,11 @@ import ( "os" ) -// This is for testing purpose only. We don't need to or should not use IOpipe in normal flow. -// We redirect os.Stdin for mocking tests so that we don't need to pass commands.IO, which -// included an os.Stdin, to all the server command. It is not safe to expose os.Stdin in the blockchain node +// This is for testing purposes only. +// For mocking tests, we redirect os.Stdin so that we don't need to pass commands.IO, +// which includes os.Stdin, to all the server commands. Exposing os.Stdin in a blockchain node is not safe. +// This replaces the global variable and should not be used in concurrent tests. It's intended to simulate CLI input. +// We purposely avoid using a mutex to prevent giving the wrong impression that it's suitable for parallel tests. type MockStdin struct { origStdout *os.File @@ -95,3 +97,20 @@ func (i *MockStdin) ReadAndClose() ([]byte, error) { return out, nil } + +// Call this in a defer function to restore and close os.Stdout and os.Stdin. +// This acts as a safeguard. +func (i *MockStdin) Close() { + os.Stdout = i.origStdout + os.Stdin = i.origStdin + + if i.stdoutReader != nil { + i.stdoutReader.Close() + i.stdoutReader = nil + } + + if i.stdinWriter != nil { + i.stdinWriter.Close() + i.stdinWriter = nil + } +} diff --git a/gno.land/cmd/gnoland/start_test.go b/gno.land/cmd/gnoland/start_test.go index aa734d11db0..ac35f2d0b31 100644 --- a/gno.land/cmd/gnoland/start_test.go +++ b/gno.land/cmd/gnoland/start_test.go @@ -23,6 +23,7 @@ func TestStartInitialize(t *testing.T) { for _, tc := range cases { name := strings.Join(tc.args, " ") in, err := NewMockStdin(name) + defer in.Close() if err != nil { t.Fatal("failed creating test io pipe") }