diff --git a/CHANGELOG.md b/CHANGELOG.md index 49b19e0672..2e75520180 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -110,6 +110,7 @@ Ref: https://keepachangelog.com/en/1.0.0/ * (x/auth) [#15867](https://github.com/cosmos/cosmos-sdk/pull/15867) Support better logging for signature verification failure. * (types/query) [#16041](https://github.com/cosmos/cosmos-sdk/pull/16041) change pagination max limit to a variable in order to be modifed by application devs * (server) [#16061](https://github.com/cosmos/cosmos-sdk/pull/16061) add comet bootstrap command +* (store) [#16067](https://github.com/cosmos/cosmos-sdk/pull/16067) Add local snapshots management commands. ### State Machine Breaking diff --git a/client/snapshot/cmd.go b/client/snapshot/cmd.go new file mode 100644 index 0000000000..f49f2b51c2 --- /dev/null +++ b/client/snapshot/cmd.go @@ -0,0 +1,24 @@ +package snapshot + +import ( + servertypes "github.com/cosmos/cosmos-sdk/server/types" + "github.com/spf13/cobra" +) + +// Cmd returns the snapshots group command +func Cmd(appCreator servertypes.AppCreator) *cobra.Command { + cmd := &cobra.Command{ + Use: "snapshots", + Short: "Manage local snapshots", + Long: "Manage local snapshots", + } + cmd.AddCommand( + ListSnapshotsCmd, + RestoreSnapshotCmd(appCreator), + ExportSnapshotCmd(appCreator), + DumpArchiveCmd(), + LoadArchiveCmd(), + DeleteSnapshotCmd(), + ) + return cmd +} diff --git a/client/snapshot/delete.go b/client/snapshot/delete.go new file mode 100644 index 0000000000..0259032e11 --- /dev/null +++ b/client/snapshot/delete.go @@ -0,0 +1,35 @@ +package snapshot + +import ( + "strconv" + + "github.com/cosmos/cosmos-sdk/server" + "github.com/spf13/cobra" +) + +func DeleteSnapshotCmd() *cobra.Command { + return &cobra.Command{ + Use: "delete ", + Short: "Delete a local snapshot", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := server.GetServerContextFromCmd(cmd) + + height, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + return err + } + format, err := strconv.ParseUint(args[1], 10, 32) + if err != nil { + return err + } + + snapshotStore, err := server.GetSnapshotStore(ctx.Viper) + if err != nil { + return err + } + + return snapshotStore.Delete(height, uint32(format)) + }, + } +} diff --git a/client/snapshot/dump.go b/client/snapshot/dump.go new file mode 100644 index 0000000000..70f223a590 --- /dev/null +++ b/client/snapshot/dump.go @@ -0,0 +1,119 @@ +package snapshot + +import ( + "archive/tar" + "compress/gzip" + "fmt" + "io" + "os" + "strconv" + + "github.com/cosmos/cosmos-sdk/server" + "github.com/spf13/cobra" +) + +// DumpArchiveCmd returns a command to dump the snapshot as portable archive format +func DumpArchiveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "dump ", + Short: "Dump the snapshot as portable archive format", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := server.GetServerContextFromCmd(cmd) + snapshotStore, err := server.GetSnapshotStore(ctx.Viper) + if err != nil { + return err + } + + output, err := cmd.Flags().GetString("output") + if err != nil { + return err + } + + height, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + return err + } + format, err := strconv.ParseUint(args[1], 10, 32) + if err != nil { + return err + } + + if output == "" { + output = fmt.Sprintf("%d-%d.tar.gz", height, format) + } + + snapshot, err := snapshotStore.Get(height, uint32(format)) + if err != nil { + return err + } + + bz, err := snapshot.Marshal() + if err != nil { + return err + } + + fp, err := os.Create(output) + if err != nil { + return err + } + defer fp.Close() + + // since the chunk files are already compressed, we just use fastest compression here + gzipWriter, err := gzip.NewWriterLevel(fp, gzip.BestSpeed) + if err != nil { + return err + } + tarWriter := tar.NewWriter(gzipWriter) + if err := tarWriter.WriteHeader(&tar.Header{ + Name: SnapshotFileName, + Mode: 0o644, + Size: int64(len(bz)), + }); err != nil { + return fmt.Errorf("failed to write snapshot header to tar: %w", err) + } + if _, err := tarWriter.Write(bz); err != nil { + return fmt.Errorf("failed to write snapshot to tar: %w", err) + } + + for i := uint32(0); i < snapshot.Chunks; i++ { + path := snapshotStore.PathChunk(height, uint32(format), i) + file, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open chunk file %s: %w", path, err) + } + + st, err := file.Stat() + if err != nil { + return fmt.Errorf("failed to stat chunk file %s: %w", path, err) + } + + if err := tarWriter.WriteHeader(&tar.Header{ + Name: strconv.FormatUint(uint64(i), 10), + Mode: 0o644, + Size: st.Size(), + }); err != nil { + return fmt.Errorf("failed to write chunk header to tar: %w", err) + } + + if _, err := io.Copy(tarWriter, file); err != nil { + return fmt.Errorf("failed to write chunk to tar: %w", err) + } + } + + if err := tarWriter.Close(); err != nil { + return fmt.Errorf("failed to close tar writer: %w", err) + } + + if err := gzipWriter.Close(); err != nil { + return fmt.Errorf("failed to close gzip writer: %w", err) + } + + return fp.Close() + }, + } + + cmd.Flags().StringP("output", "o", "", "output file") + + return cmd +} diff --git a/client/snapshot/export.go b/client/snapshot/export.go new file mode 100644 index 0000000000..6cef753138 --- /dev/null +++ b/client/snapshot/export.go @@ -0,0 +1,54 @@ +package snapshot + +import ( + "fmt" + + "cosmossdk.io/log" + "github.com/cosmos/cosmos-sdk/server" + servertypes "github.com/cosmos/cosmos-sdk/server/types" + "github.com/spf13/cobra" +) + +// ExportSnapshotCmd returns a command to take a snapshot of the application state +func ExportSnapshotCmd(appCreator servertypes.AppCreator) *cobra.Command { + cmd := &cobra.Command{ + Use: "export", + Short: "Export app state to snapshot store", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + ctx := server.GetServerContextFromCmd(cmd) + + height, err := cmd.Flags().GetInt64("height") + if err != nil { + return err + } + + home := ctx.Config.RootDir + db, err := openDB(home, server.GetAppDBBackend(ctx.Viper)) + if err != nil { + return err + } + logger := log.NewLogger(cmd.OutOrStdout()) + app := appCreator(logger, db, nil, ctx.Viper) + + if height == 0 { + height = app.CommitMultiStore().LastCommitID().Version + } + + fmt.Printf("Exporting snapshot for height %d\n", height) + + sm := app.SnapshotManager() + snapshot, err := sm.Create(uint64(height)) + if err != nil { + return err + } + + fmt.Printf("Snapshot created at height %d, format %d, chunks %d\n", snapshot.Height, snapshot.Format, snapshot.Chunks) + return nil + }, + } + + cmd.Flags().Int64("height", 0, "Height to export, default to latest state height") + + return cmd +} diff --git a/client/snapshot/list.go b/client/snapshot/list.go new file mode 100644 index 0000000000..6ff6391d42 --- /dev/null +++ b/client/snapshot/list.go @@ -0,0 +1,30 @@ +package snapshot + +import ( + "fmt" + + "github.com/cosmos/cosmos-sdk/server" + "github.com/spf13/cobra" +) + +// ListSnapshotsCmd returns the command to list local snapshots +var ListSnapshotsCmd = &cobra.Command{ + Use: "list", + Short: "List local snapshots", + RunE: func(cmd *cobra.Command, args []string) error { + ctx := server.GetServerContextFromCmd(cmd) + snapshotStore, err := server.GetSnapshotStore(ctx.Viper) + if err != nil { + return err + } + snapshots, err := snapshotStore.List() + if err != nil { + return fmt.Errorf("failed to list snapshots: %w", err) + } + for _, snapshot := range snapshots { + fmt.Println("height:", snapshot.Height, "format:", snapshot.Format, "chunks:", snapshot.Chunks) + } + + return nil + }, +} diff --git a/client/snapshot/load.go b/client/snapshot/load.go new file mode 100644 index 0000000000..b2f33dac0e --- /dev/null +++ b/client/snapshot/load.go @@ -0,0 +1,113 @@ +package snapshot + +import ( + "archive/tar" + "bytes" + "compress/gzip" + "fmt" + "io" + "os" + "reflect" + "strconv" + + "github.com/cosmos/cosmos-sdk/server" + "github.com/spf13/cobra" + + snapshottypes "cosmossdk.io/store/snapshots/types" +) + +const SnapshotFileName = "_snapshot" + +// LoadArchiveCmd load a portable archive format snapshot into snapshot store +func LoadArchiveCmd() *cobra.Command { + return &cobra.Command{ + Use: "load ", + Short: "Load a snapshot archive file into snapshot store", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := server.GetServerContextFromCmd(cmd) + snapshotStore, err := server.GetSnapshotStore(ctx.Viper) + if err != nil { + return err + } + + path := args[0] + fp, err := os.Open(path) + if err != nil { + return fmt.Errorf("failed to open archive file: %w", err) + } + reader, err := gzip.NewReader(fp) + if err != nil { + return fmt.Errorf("failed to create gzip reader: %w", err) + } + + var snapshot snapshottypes.Snapshot + tr := tar.NewReader(reader) + if err != nil { + return fmt.Errorf("failed to create tar reader: %w", err) + } + + hdr, err := tr.Next() + if err != nil { + return fmt.Errorf("failed to read snapshot file header: %w", err) + } + if hdr.Name != SnapshotFileName { + return fmt.Errorf("invalid archive, expect file: snapshot, got: %s", hdr.Name) + } + bz, err := io.ReadAll(tr) + if err != nil { + return fmt.Errorf("failed to read snapshot file: %w", err) + } + if err := snapshot.Unmarshal(bz); err != nil { + return fmt.Errorf("failed to unmarshal snapshot: %w", err) + } + + // make sure the channel is unbuffered, because the tar reader can't do concurrency + chunks := make(chan io.ReadCloser) + quitChan := make(chan *snapshottypes.Snapshot) + go func() { + defer close(quitChan) + + savedSnapshot, err := snapshotStore.Save(snapshot.Height, snapshot.Format, chunks) + if err != nil { + fmt.Println("failed to save snapshot", err) + return + } + quitChan <- savedSnapshot + }() + + for i := uint32(0); i < snapshot.Chunks; i++ { + hdr, err = tr.Next() + if err != nil { + if err == io.EOF { + break + } + return err + } + + if hdr.Name != strconv.FormatInt(int64(i), 10) { + return fmt.Errorf("invalid archive, expect file: %d, got: %s", i, hdr.Name) + } + + bz, err := io.ReadAll(tr) + if err != nil { + return fmt.Errorf("failed to read chunk file: %w", err) + } + chunks <- io.NopCloser(bytes.NewReader(bz)) + } + close(chunks) + + savedSnapshot := <-quitChan + if savedSnapshot == nil { + return fmt.Errorf("failed to save snapshot") + } + + if !reflect.DeepEqual(&snapshot, savedSnapshot) { + _ = snapshotStore.Delete(snapshot.Height, snapshot.Format) + return fmt.Errorf("invalid archive, the saved snapshot is not equal to the original one") + } + + return nil + }, + } +} diff --git a/client/snapshot/restore.go b/client/snapshot/restore.go new file mode 100644 index 0000000000..5d3f450992 --- /dev/null +++ b/client/snapshot/restore.go @@ -0,0 +1,52 @@ +package snapshot + +import ( + "path/filepath" + "strconv" + + "cosmossdk.io/log" + "github.com/spf13/cobra" + + dbm "github.com/cosmos/cosmos-db" + "github.com/cosmos/cosmos-sdk/server" + servertypes "github.com/cosmos/cosmos-sdk/server/types" +) + +// RestoreSnapshotCmd returns a command to restore a snapshot +func RestoreSnapshotCmd(appCreator servertypes.AppCreator) *cobra.Command { + cmd := &cobra.Command{ + Use: "restore ", + Short: "Restore app state from local snapshot", + Long: "Restore app state from local snapshot", + Args: cobra.ExactArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + ctx := server.GetServerContextFromCmd(cmd) + + height, err := strconv.ParseUint(args[0], 10, 64) + if err != nil { + return err + } + format, err := strconv.ParseUint(args[1], 10, 32) + if err != nil { + return err + } + + home := ctx.Config.RootDir + db, err := openDB(home, server.GetAppDBBackend(ctx.Viper)) + if err != nil { + return err + } + logger := log.NewLogger(cmd.OutOrStdout()) + app := appCreator(logger, db, nil, ctx.Viper) + + sm := app.SnapshotManager() + return sm.RestoreLocalSnapshot(height, uint32(format)) + }, + } + return cmd +} + +func openDB(rootDir string, backendType dbm.BackendType) (dbm.DB, error) { + dataDir := filepath.Join(rootDir, "data") + return dbm.NewDB("application", backendType, dataDir) +} diff --git a/go.mod b/go.mod index 85a8503577..50a1942af8 100644 --- a/go.mod +++ b/go.mod @@ -130,6 +130,7 @@ require ( github.com/mitchellh/go-testing-interface v1.14.1 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mtibben/percent v0.2.1 // indirect + github.com/nxadm/tail v1.4.8 // indirect github.com/oklog/run v1.1.0 // indirect github.com/pelletier/go-toml/v2 v2.0.7 // indirect github.com/petermattis/goid v0.0.0-20230317030725-371a4b8eda08 // indirect diff --git a/go.sum b/go.sum index 4502b12d53..4a51dbcfab 100644 --- a/go.sum +++ b/go.sum @@ -634,8 +634,9 @@ github.com/nats-io/nkeys v0.1.0/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxzi github.com/nats-io/nkeys v0.1.3/go.mod h1:xpnFELMwJABBLVhffcfd1MZx6VsNRFpEugbxziKVo7w= github.com/nats-io/nuid v1.0.1/go.mod h1:19wcPz3Ph3q0Jbyiqsd0kePYG7A95tJPxeL+1OSON2c= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= -github.com/nxadm/tail v1.4.4 h1:DQuhQpB1tVlglWS2hLQ5OV6B5r8aGxSrPc5Qo6uTN78= github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= github.com/oklog/oklog v0.3.2/go.mod h1:FCV+B7mhrz4o+ueLpx+KqkyXRGMWOYEvfiXtdGtbWGs= github.com/oklog/run v1.0.0/go.mod h1:dlhp/R75TPv97u0XWUtDeV/lRKWPKSdTuV0TZvrmrQA= github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA= diff --git a/server/types/app.go b/server/types/app.go index 3b51fe1920..1af0b0bf57 100644 --- a/server/types/app.go +++ b/server/types/app.go @@ -5,6 +5,7 @@ import ( "io" "cosmossdk.io/log" + "cosmossdk.io/store/snapshots" storetypes "cosmossdk.io/store/types" abci "github.com/cometbft/cometbft/abci/types" cmtproto "github.com/cometbft/cometbft/proto/tendermint/types" @@ -54,6 +55,9 @@ type ( // CommitMultiStore return the multistore instance CommitMultiStore() storetypes.CommitMultiStore + + // Return the snapshot manager + SnapshotManager() *snapshots.Manager } // AppCreator is a function that allows us to lazily initialize an diff --git a/server/util.go b/server/util.go index fb3105e711..16cd9a9fe4 100644 --- a/server/util.go +++ b/server/util.go @@ -469,16 +469,7 @@ func DefaultBaseappOptions(appOpts types.AppOptions) []func(*baseapp.BaseApp) { chainID = appGenesis.ChainID } - snapshotDir := filepath.Join(homeDir, "data", "snapshots") - if err = os.MkdirAll(snapshotDir, os.ModePerm); err != nil { - panic(fmt.Errorf("failed to create snapshots directory: %w", err)) - } - - snapshotDB, err := dbm.NewDB("metadata", GetAppDBBackend(appOpts), snapshotDir) - if err != nil { - panic(err) - } - snapshotStore, err := snapshots.NewStore(snapshotDB, snapshotDir) + snapshotStore, err := GetSnapshotStore(appOpts) if err != nil { panic(err) } @@ -514,3 +505,22 @@ func DefaultBaseappOptions(appOpts types.AppOptions) []func(*baseapp.BaseApp) { baseapp.SetChainID(chainID), } } + +func GetSnapshotStore(appOpts types.AppOptions) (*snapshots.Store, error) { + homeDir := cast.ToString(appOpts.Get(flags.FlagHome)) + snapshotDir := filepath.Join(homeDir, "data", "snapshots") + if err := os.MkdirAll(snapshotDir, os.ModePerm); err != nil { + return nil, fmt.Errorf("failed to create snapshots directory: %w", err) + } + + snapshotDB, err := dbm.NewDB("metadata", GetAppDBBackend(appOpts), snapshotDir) + if err != nil { + return nil, err + } + snapshotStore, err := snapshots.NewStore(snapshotDB, snapshotDir) + if err != nil { + return nil, err + } + + return snapshotStore, nil +} diff --git a/simapp/simd/cmd/root.go b/simapp/simd/cmd/root.go index bf287893e8..ce7ba31716 100644 --- a/simapp/simd/cmd/root.go +++ b/simapp/simd/cmd/root.go @@ -24,6 +24,7 @@ import ( "github.com/cosmos/cosmos-sdk/client/keys" "github.com/cosmos/cosmos-sdk/client/pruning" "github.com/cosmos/cosmos-sdk/client/rpc" + "github.com/cosmos/cosmos-sdk/client/snapshot" "github.com/cosmos/cosmos-sdk/codec" "github.com/cosmos/cosmos-sdk/server" serverconfig "github.com/cosmos/cosmos-sdk/server/config" @@ -191,6 +192,7 @@ func initRootCmd(rootCmd *cobra.Command, encodingConfig params.EncodingConfig, b debug.Cmd(), confixcmd.ConfigCommand(), pruning.Cmd(newApp), + snapshot.Cmd(newApp), ) server.AddCommands(rootCmd, simapp.DefaultNodeHome, newApp, appExport, addModuleInitFlags) diff --git a/simapp/simd/cmd/root_v2.go b/simapp/simd/cmd/root_v2.go index db8c0298af..bfc8079414 100644 --- a/simapp/simd/cmd/root_v2.go +++ b/simapp/simd/cmd/root_v2.go @@ -25,6 +25,7 @@ import ( "github.com/cosmos/cosmos-sdk/client/keys" "github.com/cosmos/cosmos-sdk/client/pruning" "github.com/cosmos/cosmos-sdk/client/rpc" + "github.com/cosmos/cosmos-sdk/client/snapshot" "github.com/cosmos/cosmos-sdk/codec" codectypes "github.com/cosmos/cosmos-sdk/codec/types" "github.com/cosmos/cosmos-sdk/server" @@ -207,6 +208,7 @@ func initRootCmd( debug.Cmd(), confixcmd.ConfigCommand(), pruning.Cmd(newApp), + snapshot.Cmd(newApp), ) server.AddCommands(rootCmd, simapp.DefaultNodeHome, newApp, appExport, addModuleInitFlags) diff --git a/store/snapshots/manager.go b/store/snapshots/manager.go index 90e980d4d0..4401613aab 100644 --- a/store/snapshots/manager.go +++ b/store/snapshots/manager.go @@ -418,6 +418,25 @@ func (m *Manager) RestoreChunk(chunk []byte) (bool, error) { return false, nil } +// RestoreLocalSnapshot restores app state from a local snapshot. +func (m *Manager) RestoreLocalSnapshot(height uint64, format uint32) error { + snapshot, ch, err := m.store.Load(height, format) + if err != nil { + return err + } + + m.mtx.Lock() + defer m.mtx.Unlock() + + err = m.beginLocked(opRestore) + if err != nil { + return err + } + defer m.endLocked() + + return m.restoreSnapshot(*snapshot, ch) +} + // sortedExtensionNames sort extension names for deterministic iteration. func (m *Manager) sortedExtensionNames() []string { names := make([]string, 0, len(m.extensions)) diff --git a/store/snapshots/store.go b/store/snapshots/store.go index 91629eaaa1..284707329e 100644 --- a/store/snapshots/store.go +++ b/store/snapshots/store.go @@ -167,7 +167,7 @@ func (s *Store) Load(height uint64, format uint32) (*types.Snapshot, <-chan io.R // LoadChunk loads a chunk from disk, or returns nil if it does not exist. The caller must call // Close() on it when done. func (s *Store) LoadChunk(height uint64, format, chunk uint32) (io.ReadCloser, error) { - path := s.pathChunk(height, format, chunk) + path := s.PathChunk(height, format, chunk) file, err := os.Open(path) if os.IsNotExist(err) { return nil, nil @@ -177,7 +177,7 @@ func (s *Store) LoadChunk(height uint64, format, chunk uint32) (io.ReadCloser, e // loadChunkFile loads a chunk from disk, and errors if it does not exist. func (s *Store) loadChunkFile(height uint64, format, chunk uint32) (io.ReadCloser, error) { - path := s.pathChunk(height, format, chunk) + path := s.PathChunk(height, format, chunk) return os.Open(path) } @@ -291,7 +291,7 @@ func (s *Store) Save( func (s *Store) saveChunk(chunkBody io.ReadCloser, index uint32, snapshot *types.Snapshot, chunkHasher, snapshotHasher hash.Hash) error { defer chunkBody.Close() - path := s.pathChunk(snapshot.Height, snapshot.Format, index) + path := s.PathChunk(snapshot.Height, snapshot.Format, index) chunkFile, err := os.Create(path) if err != nil { return errors.Wrapf(err, "failed to create snapshot chunk file %q", path) @@ -335,8 +335,8 @@ func (s *Store) pathSnapshot(height uint64, format uint32) string { return filepath.Join(s.pathHeight(height), strconv.FormatUint(uint64(format), 10)) } -// pathChunk generates a snapshot chunk path. -func (s *Store) pathChunk(height uint64, format, chunk uint32) string { +// PathChunk generates a snapshot chunk path. +func (s *Store) PathChunk(height uint64, format, chunk uint32) string { return filepath.Join(s.pathSnapshot(height, format), strconv.FormatUint(uint64(chunk), 10)) }