From d43588f5d39731fa922a635b8cd9452c33be62fe Mon Sep 17 00:00:00 2001 From: shakezula Date: Thu, 27 Jul 2023 12:50:37 -0600 Subject: [PATCH 01/12] [wip] skeleton of the save command wired up and test failing --- app/client/cli/node.go | 21 ++++++++++++++++++++- e2e/tests/node.feature | 8 ++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) create mode 100644 e2e/tests/node.feature diff --git a/app/client/cli/node.go b/app/client/cli/node.go index 96a8674c6..a841b2a5c 100644 --- a/app/client/cli/node.go +++ b/app/client/cli/node.go @@ -1,6 +1,10 @@ package cli -import "github.com/spf13/cobra" +import ( + "fmt" + + "github.com/spf13/cobra" +) func init() { nodeCmd := NewNodeCommand() @@ -14,5 +18,20 @@ func NewNodeCommand() *cobra.Command { Aliases: []string{"node", "n"}, } + cmd.AddCommand(nodeSaveCommands()...) + return cmd } + +func nodeSaveCommands() []*cobra.Command { + cmds := []*cobra.Command{ + { + Use: "Save", + RunE: func(cmd *cobra.Command, args []string) error { + return fmt.Errorf("not impl") + }, + Short: "save a backup of world state", + }, + } + return cmds +} diff --git a/e2e/tests/node.feature b/e2e/tests/node.feature new file mode 100644 index 000000000..823f3bc55 --- /dev/null +++ b/e2e/tests/node.feature @@ -0,0 +1,8 @@ +# TECHDEBT: Validator should eventually be changed to full node or just node. +Feature: Node Namespace + + Scenario: User Wants Help Using The Node Command + Given the user has a validator + When the user runs the command "Node help" + Then the user should be able to see standard output containing "Available Commands" + And the validator should have exited without error From 411e64e03be9f740a58af7bbf2acdad34eeae6d8 Mon Sep 17 00:00:00 2001 From: shakezula Date: Fri, 28 Jul 2023 13:05:17 -0600 Subject: [PATCH 02/12] [wip] adds v1/node/backup handlers and api spec --- app/docs/CHANGELOG.md | 5 +++++ rpc/handlers_node.go | 11 +++++++++++ rpc/v1/openapi.yaml | 36 ++++++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+) create mode 100644 rpc/handlers_node.go diff --git a/app/docs/CHANGELOG.md b/app/docs/CHANGELOG.md index 1b2098eb3..f48881158 100644 --- a/app/docs/CHANGELOG.md +++ b/app/docs/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.0.0.9] - 2023-07-28 + +- Adds the `Save` sub-command CLI +- Adds `/v1/node/backup` to the OpenAPI spec + ## [0.0.0.8] - 2023-06-06 - Adds `query nodeRoles` sub-command the client CLI diff --git a/rpc/handlers_node.go b/rpc/handlers_node.go new file mode 100644 index 000000000..041481028 --- /dev/null +++ b/rpc/handlers_node.go @@ -0,0 +1,11 @@ +package rpc + +import ( + "fmt" + + "github.com/labstack/echo/v4" +) + +func (s *rpcServer) PostV1NodeBackup(ctx echo.Context) error { + return fmt.Errorf("not impl") +} diff --git a/rpc/v1/openapi.yaml b/rpc/v1/openapi.yaml index c068b4238..39d915df3 100644 --- a/rpc/v1/openapi.yaml +++ b/rpc/v1/openapi.yaml @@ -951,6 +951,42 @@ paths: node_roles: - "validator" - "servicer" + /v1/node/backup: + post: + summary: Create a backup of all node databases + description: Create a backup at the specified location + requestBody: + required: true + content: + multipart/form-data: + schema: + type: object + properties: + file: + type: string + format: binary + responses: + '200': + description: OK + content: + application/json: + schema: + type: object + properties: + location: + type: string + description: The location of the saved file + '400': + description: Bad Request + content: + application/json: + schema: + type: object + properties: + error: + type: string + description: Description of the error + externalDocs: description: Find out more about Pocket Network From 1c8e140e1b889dea1c2f511e246c0a95f45d30ee Mon Sep 17 00:00:00 2001 From: shakezula Date: Fri, 28 Jul 2023 16:54:37 -0600 Subject: [PATCH 03/12] [wip] load from directory test almost working --- app/client/cli/node.go | 29 +++++++++-- persistence/trees/trees.go | 82 +++++++++++++++++++++++++----- rpc/handlers_node.go | 17 +++++-- rpc/v1/openapi.yaml | 53 +++++++++---------- shared/modules/treestore_module.go | 6 +++ 5 files changed, 142 insertions(+), 45 deletions(-) diff --git a/app/client/cli/node.go b/app/client/cli/node.go index a841b2a5c..1e8245200 100644 --- a/app/client/cli/node.go +++ b/app/client/cli/node.go @@ -3,6 +3,8 @@ package cli import ( "fmt" + "github.com/pokt-network/pocket/app/client/cli/flags" + "github.com/pokt-network/pocket/rpc" "github.com/spf13/cobra" ) @@ -11,6 +13,10 @@ func init() { rootCmd.AddCommand(nodeCmd) } +var ( + dir string +) + func NewNodeCommand() *cobra.Command { cmd := &cobra.Command{ Use: "Node", @@ -26,11 +32,28 @@ func NewNodeCommand() *cobra.Command { func nodeSaveCommands() []*cobra.Command { cmds := []*cobra.Command{ { - Use: "Save", + Use: "Save", + Short: "save a backup of node databases in the provided directory", + Example: "node save --dir /dir/path/here/", RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("not impl") + client, err := rpc.NewClientWithResponses(flags.RemoteCLIURL) + if err != nil { + return err + } + resp, err := client.PostV1NodeBackup(cmd.Context(), rpc.NodeBackup{ + Dir: &dir, + }) + if err != nil { + return err + } + var dest []byte + _, err = resp.Body.Read(dest) + if err != nil { + return err + } + fmt.Printf("%s", dest) + return nil }, - Short: "save a backup of world state", }, } return cmds diff --git a/persistence/trees/trees.go b/persistence/trees/trees.go index 8c5c936bb..1b5b97724 100644 --- a/persistence/trees/trees.go +++ b/persistence/trees/trees.go @@ -15,9 +15,10 @@ package trees import ( "crypto/sha256" "encoding/hex" - "errors" + "encoding/json" "fmt" "hash" + "io/ioutil" "log" "path/filepath" @@ -329,13 +330,26 @@ func (t *treeStore) Rollback() error { return ErrFailedRollback } -// Load sets the TreeStore trees to the values provided in the worldstate -func (t *treeStore) Load(w *worldState) error { +// Load sets the TreeStore merkle and root trees to the values provided in the worldstate +func (t *treeStore) Load(dir string) error { + // Look for a worldstate.json file to hydrate + data, err := readFile(filepath.Join(dir, "worldstate.json")) + if err != nil { + return err + } + + // Hydrate a worldstate from the json object + var w *worldState + err = json.Unmarshal(data, &w) + if err != nil { + return err + } + t.merkleTrees = make(map[string]*stateTree) - // import root tree - rootTreePath := fmt.Sprintf("%s/%s_nodes", t.treeStoreDir, RootTreeName) - nodeStore, err := kvstore.NewKVStore(rootTreePath) + // import root tree from worldState + path := formattedTreePath(dir, RootTreeName) + nodeStore, err := kvstore.NewKVStore(path) if err != nil { return err } @@ -345,14 +359,13 @@ func (t *treeStore) Load(w *worldState) error { nodeStore: nodeStore, } - // import merkle trees + // import merkle tree roots trees from worldState for treeName, treeRootHash := range w.merkleRoots { treePath := fmt.Sprintf("%s/%s_nodes", w.treeStoreDir, treeName) nodeStore, err := kvstore.NewKVStore(treePath) if err != nil { return err } - t.merkleTrees[treeName] = &stateTree{ name: treeName, nodeStore: nodeStore, @@ -404,15 +417,31 @@ func (t *treeStore) save() (*worldState, error) { // Backup creates a new backup of each tree in the tree store to the provided directory. // Each tree is backed up in an eponymous file in the provided backupDir. func (t *treeStore) Backup(backupDir string) error { - errs := []error{} + // save all current branches + if err := t.Commit(); err != nil { + return err + } + + w := &worldState{ + rootHash: []byte(t.getStateHash()), + merkleRoots: make(map[string][]byte), + treeStoreDir: backupDir, // TODO IN THIS COMMIT make sure this is the proper formatting + } + for _, st := range t.merkleTrees { - treePath := filepath.Join(backupDir, st.name) - if err := st.nodeStore.Backup(treePath); err != nil { + if err := st.nodeStore.Backup(formattedTreePath(backupDir, st.name)); err != nil { t.logger.Err(err).Msgf("failed to backup %s tree: %+v", st.name, err) - errs = append(errs, err) + return err } + w.merkleRoots[st.name] = st.tree.Root() + } + + err := writeFile(filepath.Join(backupDir, "worldstate.json"), w) + if err != nil { + return err } - return errors.Join(errs...) + + return nil } //////////////////////// @@ -560,3 +589,30 @@ func getTransactions(txi indexer.TxIndexer, height uint64) ([]*coreTypes.Indexed } return indexedTxs, nil } + +func readFile(filePath string) ([]byte, error) { + data, err := ioutil.ReadFile(filePath) + if err != nil { + return nil, err + } + return data, nil +} + +func writeFile(filePath string, data interface{}) error { + jsonData, err := json.MarshalIndent(data, "", " ") + if err != nil { + return err + } + + err = ioutil.WriteFile(filePath, jsonData, 0644) + if err != nil { + return err + } + + return nil +} + +// defines a standard naming scheme for each tree's storage location +func formattedTreePath(dir, treeName string) string { + return fmt.Sprintf("%s/%s_nodes", dir, treeName) +} diff --git a/rpc/handlers_node.go b/rpc/handlers_node.go index 041481028..5672c209b 100644 --- a/rpc/handlers_node.go +++ b/rpc/handlers_node.go @@ -1,11 +1,22 @@ package rpc import ( - "fmt" - "github.com/labstack/echo/v4" ) func (s *rpcServer) PostV1NodeBackup(ctx echo.Context) error { - return fmt.Errorf("not impl") + store := s.GetBus().GetPersistenceModule().GetBus().GetTreeStore() + rw, err := s.GetBus().GetPersistenceModule().NewRWContext(0) + if err != nil { + return err + } + if err := rw.SetSavePoint(); err != nil { + return err + } + if err := store.Backup(ctx.Param("dir")); err != nil { + return err + } + rw.Release() + s.logger.Info().Msgf("backup created in %s", ctx.Param("dir")) + return nil } diff --git a/rpc/v1/openapi.yaml b/rpc/v1/openapi.yaml index 39d915df3..c616875c6 100644 --- a/rpc/v1/openapi.yaml +++ b/rpc/v1/openapi.yaml @@ -953,39 +953,35 @@ paths: - "servicer" /v1/node/backup: post: - summary: Create a backup of all node databases - description: Create a backup at the specified location + tags: + - node + summary: Creates a backup of all node databases in the specified directory requestBody: - required: true + description: Request backup creation in the specified dir content: - multipart/form-data: + application/json: schema: - type: object - properties: - file: - type: string - format: binary + $ref: "#/components/schemas/NodeBackup" + example: + dir: /path/to/backup/dir/ + required: true responses: - '200': - description: OK + "200": + description: Returns account data at the specified height content: application/json: schema: - type: object - properties: - location: - type: string - description: The location of the saved file - '400': - description: Bad Request + $ref: "#/components/schemas/Account" + "400": + description: Bad request content: - application/json: - schema: - type: object - properties: - error: - type: string - description: Description of the error + text/plain: + example: "TODO" + "500": + description: An error occurred while retrieving the account data at the specified height + content: + text/plain: + example: "TODO" externalDocs: @@ -1911,7 +1907,12 @@ components: type: array items: $ref: "#/components/schemas/PartialSignature" - + NodeBackup: + type: object + properties: + dir: + type: string + securitySchemes: {} links: {} callbacks: {} diff --git a/shared/modules/treestore_module.go b/shared/modules/treestore_module.go index bddc2451c..7a1605bed 100644 --- a/shared/modules/treestore_module.go +++ b/shared/modules/treestore_module.go @@ -23,6 +23,7 @@ type TreeStoreModule interface { treeStoreFactory AtomicStore + Archivable // Update returns the computed state hash for a given height. // * Height is passed through to the Update function and is used to query the TxIndexer for transactions @@ -43,3 +44,8 @@ type TreeStoreModule interface { // GetTreeHashes returns a map of tree names to their root hashes GetTreeHashes() map[string]string } + +type Archivable interface { + Backup(dir string) error + Load(dir string) error +} From df33edac1c11660260f2b9849d88e7d8e9d736cb Mon Sep 17 00:00:00 2001 From: shakezula Date: Fri, 28 Jul 2023 20:49:26 -0600 Subject: [PATCH 04/12] [fixup] s/tmpdir/tmpDir/ --- e2e/tests/node.feature | 1 - persistence/trees/atomic_test.go | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/tests/node.feature b/e2e/tests/node.feature index 823f3bc55..d04c02b82 100644 --- a/e2e/tests/node.feature +++ b/e2e/tests/node.feature @@ -1,4 +1,3 @@ -# TECHDEBT: Validator should eventually be changed to full node or just node. Feature: Node Namespace Scenario: User Wants Help Using The Node Command diff --git a/persistence/trees/atomic_test.go b/persistence/trees/atomic_test.go index a02f2afd9..d32a979ca 100644 --- a/persistence/trees/atomic_test.go +++ b/persistence/trees/atomic_test.go @@ -171,7 +171,7 @@ func TestTreeStore_SaveAndLoad(t *testing.T) { } // Load sets a tree store to the provided worldstate - err = ts2.Load(w) + err = ts2.Load(tmpDir) require.NoError(t, err) hash2 := ts2.getStateHash() From c054b7cef2a63e15dd7bbfbdda884eecf96317a4 Mon Sep 17 00:00:00 2001 From: shakezula Date: Tue, 1 Aug 2023 15:14:53 -0600 Subject: [PATCH 05/12] [wip] introduces worldStateJson struct * introduces the worldStateJson struct to properly export the necessary fields for worldstate marshaling and unmarshaling * test still fails though --- persistence/trees/atomic_test.go | 81 ++++++++++++++++++++------------ persistence/trees/trees.go | 33 ++++++++----- 2 files changed, 73 insertions(+), 41 deletions(-) diff --git a/persistence/trees/atomic_test.go b/persistence/trees/atomic_test.go index d32a979ca..c98f10afd 100644 --- a/persistence/trees/atomic_test.go +++ b/persistence/trees/atomic_test.go @@ -2,6 +2,7 @@ package trees import ( "encoding/hex" + "fmt" "io" "os" "testing" @@ -112,23 +113,50 @@ func TestTreeStore_SaveAndLoad(t *testing.T) { t.Parallel() t.Run("should save a backup in a directory", func(t *testing.T) { ts := newTestTreeStore(t) - tmpdir := t.TempDir() + backupDir := t.TempDir() // assert that the directory is empty before backup - ok, err := isEmpty(tmpdir) + ok, err := isEmpty(backupDir) require.NoError(t, err) require.True(t, ok) // Trigger a backup - require.NoError(t, ts.Backup(tmpdir)) + require.NoError(t, ts.Backup(backupDir)) // assert that the directory is not empty after Backup has returned - ok, err = isEmpty(tmpdir) + ok, err = isEmpty(backupDir) require.NoError(t, err) require.False(t, ok) + + // assert that the worldstate.json file exists after a backup + + // Open the directory + dir, err := os.Open(backupDir) + if err != nil { + fmt.Printf("Error opening directory: %s\n", err) + return + } + defer dir.Close() + + // Read directory entries one by one + files, err := dir.Readdir(0) // 0 means read all directory entries + if err != nil { + fmt.Printf("Error reading directory entries: %s\n", err) + return + } + require.Equal(t, len(files), len(stateTreeNames)+1) // +1 to account for the worldstate file + + // Now files is a slice of FileInfo objects representing the directory entries + // You can work with them as needed. + for _, file := range files { + if file.IsDir() { + fmt.Printf("Directory: %s\n", file.Name()) + } else { + fmt.Printf("File: %s\n", file.Name()) + } + } }) t.Run("should load a backup and maintain TreeStore hash integrity", func(t *testing.T) { ctrl := gomock.NewController(t) - tmpDir := t.TempDir() mockTxIndexer := mock_types.NewMockTxIndexer(ctrl) mockBus := mock_modules.NewMockBus(ctrl) @@ -137,46 +165,39 @@ func TestTreeStore_SaveAndLoad(t *testing.T) { mockBus.EXPECT().GetPersistenceModule().AnyTimes().Return(mockPersistenceMod) mockPersistenceMod.EXPECT().GetTxIndexer().AnyTimes().Return(mockTxIndexer) - ts := &treeStore{ - logger: logger.Global.CreateLoggerForModule(modules.TreeStoreSubmoduleName), - treeStoreDir: tmpDir, - } - require.NoError(t, ts.Start()) - require.NotNil(t, ts.rootTree.tree) - - for _, treeName := range stateTreeNames { - err := ts.merkleTrees[treeName].tree.Update([]byte("foo"), []byte("bar")) - require.NoError(t, err) - } + // create a new tree store and save it's initial hash + ts := newTestTreeStore(t) + hash1 := ts.getStateHash() - err := ts.Commit() + // make a temp directory for the backup and assert it's empty + backupDir := t.TempDir() + empty, err := isEmpty(backupDir) require.NoError(t, err) + require.True(t, empty) - hash1 := ts.getStateHash() - require.NotEmpty(t, hash1) + // make a backup + err = ts.Backup(backupDir) + require.NoError(t, err) - w, err := ts.save() + // assert directory is not empty after backup + empty2, err := isEmpty(backupDir) require.NoError(t, err) - require.NotNil(t, w) - require.NotNil(t, w.rootHash) - require.NotNil(t, w.merkleRoots) + require.False(t, empty2) - // Stop the first tree store so that it's databases are no longer used + // stop the first tree store so that it's databases are released require.NoError(t, ts.Stop()) // declare a second TreeStore with no trees then load the first worldstate into it ts2 := &treeStore{ - logger: logger.Global.CreateLoggerForModule(modules.TreeStoreSubmoduleName), - treeStoreDir: tmpDir, + logger: logger.Global.CreateLoggerForModule(modules.TreeStoreSubmoduleName), } - // Load sets a tree store to the provided worldstate - err = ts2.Load(tmpDir) + // call load with the backup directory + err = ts2.Load(backupDir) require.NoError(t, err) - hash2 := ts2.getStateHash() - // Assert that hash is unchanged from save and load + hash2 := ts2.getStateHash() require.Equal(t, hash1, hash2) }) } diff --git a/persistence/trees/trees.go b/persistence/trees/trees.go index 1b5b97724..f499e9102 100644 --- a/persistence/trees/trees.go +++ b/persistence/trees/trees.go @@ -118,6 +118,16 @@ type worldState struct { merkleRoots map[string][]byte } +// worldStateJson holds exported members for proper JSON marshaling and unmarshaling. +// It contains the root hash of the merkle roots as a byte slice and a map of the MerkleRoots +// where each key is the name of the file in the same directory that corresponds to the baderDB +// backup file for that tree. That tree's hash is the value of that object for checking the integrity +// of each file and tree. +type worldStateJson struct { + RootHash []byte + MerkleRoots map[string][]byte // +} + // GetTree returns the root hash and nodeStore for the matching tree stored in the TreeStore. // This enables the caller to import the SMT without changing the one stored unless they call // `Commit()` to write to the nodestore. @@ -299,7 +309,8 @@ func (t *treeStore) getStateHash() string { } // Convert the array to a slice and return it // REF: https://stackoverflow.com/questions/28886616/convert-array-to-slice-in-go - hexHash := hex.EncodeToString(t.rootTree.tree.Root()) + root := t.rootTree.tree.Root() + hexHash := hex.EncodeToString(root) t.logger.Info().Msgf("#️⃣ calculated state hash: %s", hexHash) return hexHash } @@ -339,7 +350,7 @@ func (t *treeStore) Load(dir string) error { } // Hydrate a worldstate from the json object - var w *worldState + var w *worldStateJson err = json.Unmarshal(data, &w) if err != nil { return err @@ -355,13 +366,13 @@ func (t *treeStore) Load(dir string) error { } t.rootTree = &stateTree{ name: RootTreeName, - tree: smt.ImportSparseMerkleTree(nodeStore, smtTreeHasher, w.rootHash), + tree: smt.ImportSparseMerkleTree(nodeStore, smtTreeHasher, w.RootHash), nodeStore: nodeStore, } // import merkle tree roots trees from worldState - for treeName, treeRootHash := range w.merkleRoots { - treePath := fmt.Sprintf("%s/%s_nodes", w.treeStoreDir, treeName) + for treeName, treeRootHash := range w.MerkleRoots { + treePath := fmt.Sprintf("%s/%s_nodes", dir, treeName) nodeStore, err := kvstore.NewKVStore(treePath) if err != nil { return err @@ -422,10 +433,9 @@ func (t *treeStore) Backup(backupDir string) error { return err } - w := &worldState{ - rootHash: []byte(t.getStateHash()), - merkleRoots: make(map[string][]byte), - treeStoreDir: backupDir, // TODO IN THIS COMMIT make sure this is the proper formatting + w := &worldStateJson{ + RootHash: []byte(t.getStateHash()), + MerkleRoots: make(map[string][]byte), } for _, st := range t.merkleTrees { @@ -433,10 +443,11 @@ func (t *treeStore) Backup(backupDir string) error { t.logger.Err(err).Msgf("failed to backup %s tree: %+v", st.name, err) return err } - w.merkleRoots[st.name] = st.tree.Root() + w.MerkleRoots[st.name] = st.tree.Root() } - err := writeFile(filepath.Join(backupDir, "worldstate.json"), w) + worldstatePath := filepath.Join(backupDir, "worldstate.json") + err := writeFile(worldstatePath, w) if err != nil { return err } From 8cde5b3ac94f0baaf83f740bfec42b8c837907a8 Mon Sep 17 00:00:00 2001 From: shakezula Date: Tue, 1 Aug 2023 16:46:01 -0600 Subject: [PATCH 06/12] [wip] tests are passing for save and load --- persistence/trees/atomic_test.go | 40 +----------------------- persistence/trees/trees.go | 53 +++++++++++++++++++++----------- 2 files changed, 36 insertions(+), 57 deletions(-) diff --git a/persistence/trees/atomic_test.go b/persistence/trees/atomic_test.go index c98f10afd..da718ad85 100644 --- a/persistence/trees/atomic_test.go +++ b/persistence/trees/atomic_test.go @@ -2,7 +2,6 @@ package trees import ( "encoding/hex" - "fmt" "io" "os" "testing" @@ -126,45 +125,8 @@ func TestTreeStore_SaveAndLoad(t *testing.T) { ok, err = isEmpty(backupDir) require.NoError(t, err) require.False(t, ok) - - // assert that the worldstate.json file exists after a backup - - // Open the directory - dir, err := os.Open(backupDir) - if err != nil { - fmt.Printf("Error opening directory: %s\n", err) - return - } - defer dir.Close() - - // Read directory entries one by one - files, err := dir.Readdir(0) // 0 means read all directory entries - if err != nil { - fmt.Printf("Error reading directory entries: %s\n", err) - return - } - require.Equal(t, len(files), len(stateTreeNames)+1) // +1 to account for the worldstate file - - // Now files is a slice of FileInfo objects representing the directory entries - // You can work with them as needed. - for _, file := range files { - if file.IsDir() { - fmt.Printf("Directory: %s\n", file.Name()) - } else { - fmt.Printf("File: %s\n", file.Name()) - } - } }) t.Run("should load a backup and maintain TreeStore hash integrity", func(t *testing.T) { - ctrl := gomock.NewController(t) - - mockTxIndexer := mock_types.NewMockTxIndexer(ctrl) - mockBus := mock_modules.NewMockBus(ctrl) - mockPersistenceMod := mock_modules.NewMockPersistenceModule(ctrl) - - mockBus.EXPECT().GetPersistenceModule().AnyTimes().Return(mockPersistenceMod) - mockPersistenceMod.EXPECT().GetTxIndexer().AnyTimes().Return(mockTxIndexer) - // create a new tree store and save it's initial hash ts := newTestTreeStore(t) hash1 := ts.getStateHash() @@ -196,7 +158,7 @@ func TestTreeStore_SaveAndLoad(t *testing.T) { err = ts2.Load(backupDir) require.NoError(t, err) - // Assert that hash is unchanged from save and load + // assert that hash is unchanged from save and load hash2 := ts2.getStateHash() require.Equal(t, hash1, hash2) }) diff --git a/persistence/trees/trees.go b/persistence/trees/trees.go index f499e9102..fe3481d4b 100644 --- a/persistence/trees/trees.go +++ b/persistence/trees/trees.go @@ -18,8 +18,8 @@ import ( "encoding/json" "fmt" "hash" - "io/ioutil" "log" + "os" "path/filepath" "github.com/jackc/pgx/v5" @@ -343,30 +343,30 @@ func (t *treeStore) Rollback() error { // Load sets the TreeStore merkle and root trees to the values provided in the worldstate func (t *treeStore) Load(dir string) error { - // Look for a worldstate.json file to hydrate + // look for a worldstate.json file to hydrate data, err := readFile(filepath.Join(dir, "worldstate.json")) if err != nil { return err } - // Hydrate a worldstate from the json object + // assign tree store directory to dir if a valid worldstate.json exists + t.treeStoreDir = dir + + // hydrate a worldstate from the json object var w *worldStateJson err = json.Unmarshal(data, &w) if err != nil { return err } - t.merkleTrees = make(map[string]*stateTree) - - // import root tree from worldState - path := formattedTreePath(dir, RootTreeName) - nodeStore, err := kvstore.NewKVStore(path) + // create a new root tree and node store + nodeStore, err := kvstore.NewKVStore(fmt.Sprintf("%s/%s_nodes", t.treeStoreDir, RootTreeName)) if err != nil { return err } t.rootTree = &stateTree{ name: RootTreeName, - tree: smt.ImportSparseMerkleTree(nodeStore, smtTreeHasher, w.RootHash), + tree: smt.NewSparseMerkleTree(nodeStore, smtTreeHasher), nodeStore: nodeStore, } @@ -379,8 +379,8 @@ func (t *treeStore) Load(dir string) error { } t.merkleTrees[treeName] = &stateTree{ name: treeName, - nodeStore: nodeStore, tree: smt.ImportSparseMerkleTree(nodeStore, smtTreeHasher, treeRootHash), + nodeStore: nodeStore, } } @@ -439,7 +439,8 @@ func (t *treeStore) Backup(backupDir string) error { } for _, st := range t.merkleTrees { - if err := st.nodeStore.Backup(formattedTreePath(backupDir, st.name)); err != nil { + treePath := fmt.Sprintf("%s/%s_nodes.bak", backupDir, st.name) + if err := st.nodeStore.Backup(treePath); err != nil { t.logger.Err(err).Msgf("failed to backup %s tree: %+v", st.name, err) return err } @@ -602,28 +603,44 @@ func getTransactions(txi indexer.TxIndexer, height uint64) ([]*coreTypes.Indexed } func readFile(filePath string) ([]byte, error) { - data, err := ioutil.ReadFile(filePath) + file, err := os.Open(filePath) + if err != nil { + return nil, err + } + defer file.Close() + + // Use os.Stat to get file size and read the content into a byte slice + stat, err := file.Stat() if err != nil { return nil, err } + + data := make([]byte, stat.Size()) + _, err = file.Read(data) + if err != nil { + return nil, err + } + return data, nil } func writeFile(filePath string, data interface{}) error { + file, err := os.Create(filePath) + if err != nil { + return err + } + defer file.Close() + + // Use the json.MarshalIndent function to encode data into JSON format with indentation jsonData, err := json.MarshalIndent(data, "", " ") if err != nil { return err } - err = ioutil.WriteFile(filePath, jsonData, 0644) + _, err = file.Write(jsonData) if err != nil { return err } return nil } - -// defines a standard naming scheme for each tree's storage location -func formattedTreePath(dir, treeName string) string { - return fmt.Sprintf("%s/%s_nodes", dir, treeName) -} From abce4d3400194089f1efd271df4d8fa47310fb54 Mon Sep 17 00:00:00 2001 From: shakezula Date: Tue, 1 Aug 2023 17:04:08 -0600 Subject: [PATCH 07/12] [polish] adds logs for backup and load progress --- persistence/trees/atomic_test.go | 2 +- persistence/trees/trees.go | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/persistence/trees/atomic_test.go b/persistence/trees/atomic_test.go index da718ad85..902281b7d 100644 --- a/persistence/trees/atomic_test.go +++ b/persistence/trees/atomic_test.go @@ -160,7 +160,7 @@ func TestTreeStore_SaveAndLoad(t *testing.T) { // assert that hash is unchanged from save and load hash2 := ts2.getStateHash() - require.Equal(t, hash1, hash2) + require.Equal(t, hash1, hash2, "failed to maintain hash integrity") }) } diff --git a/persistence/trees/trees.go b/persistence/trees/trees.go index fe3481d4b..d321bc705 100644 --- a/persistence/trees/trees.go +++ b/persistence/trees/trees.go @@ -359,6 +359,8 @@ func (t *treeStore) Load(dir string) error { return err } + t.logger.Info().Msgf("🌏 worldstate detected, beginning import at %s", dir) + // create a new root tree and node store nodeStore, err := kvstore.NewKVStore(fmt.Sprintf("%s/%s_nodes", t.treeStoreDir, RootTreeName)) if err != nil { @@ -382,6 +384,7 @@ func (t *treeStore) Load(dir string) error { tree: smt.ImportSparseMerkleTree(nodeStore, smtTreeHasher, treeRootHash), nodeStore: nodeStore, } + t.logger.Info().Msgf("🌳 %s initialized at %s", treeName, hex.EncodeToString(w.MerkleRoots[treeName])) } return nil @@ -453,6 +456,8 @@ func (t *treeStore) Backup(backupDir string) error { return err } + t.logger.Info().Msgf("💾 backup created at %s", backupDir) + return nil } From 5571a40ae5c5125b0ff61fe04d02817c40f5236f Mon Sep 17 00:00:00 2001 From: shakezula Date: Thu, 3 Aug 2023 20:12:36 -0600 Subject: [PATCH 08/12] [wip] adds a test for RPC backup handler --- rpc/handlers_node.go | 96 +++++++++++++++++++++++++++++++++++++++++--- rpc/handlers_test.go | 56 ++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 rpc/handlers_test.go diff --git a/rpc/handlers_node.go b/rpc/handlers_node.go index 5672c209b..f7463adab 100644 --- a/rpc/handlers_node.go +++ b/rpc/handlers_node.go @@ -1,22 +1,106 @@ package rpc import ( + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "time" + "github.com/labstack/echo/v4" + "github.com/pokt-network/pocket/runtime/configs" ) +// PostV1NodeBackup triggers a backup of the TreeStore, the BlockStore, the PostgreSQL database. +// TECHDEBT: Run each backup process in a goroutine to as elapsed time will become significant +// with the current waterfall approach when even a moderate amount of data resides in each store. func (s *rpcServer) PostV1NodeBackup(ctx echo.Context) error { - store := s.GetBus().GetPersistenceModule().GetBus().GetTreeStore() - rw, err := s.GetBus().GetPersistenceModule().NewRWContext(0) + dir := os.TempDir() // TODO_IN_THIS_COMMIT give this a sane default and make it configurable + s.logger.Info().Msgf("creating backup in %s", dir) + + // backup the TreeStore + trees := s.GetBus().GetTreeStore() + if err := trees.Backup(dir); err != nil { + return err + } + + // backup the BlockStore + path := fmt.Sprintf("%s-blockstore-backup.sql", time.Now().String()) + if err := s.GetBus().GetPersistenceModule().GetBlockStore().Backup(path); err != nil { + return err + } + + // backup the Postgres database + cfg := s.GetBus().GetRuntimeMgr().GetConfig() + err := postgresBackup(cfg, dir) // TODO_IN_THIS_COMMIT make this point at the right directory per the tests if err != nil { return err } - if err := rw.SetSavePoint(); err != nil { + + s.logger.Info().Msgf("backup created in %s", dir) + return nil +} + +func postgresBackup(cfg *configs.Config, dir string) error { + filename := fmt.Sprintf("%s-postgres-backup.sql", time.Now().String()) + file, err := os.Create(filepath.Join(dir, filename)) + if err != nil { return err } - if err := store.Backup(ctx.Param("dir")); err != nil { + defer file.Close() + + // pgurl := cfg.Persistence.PostgresUrl + // credentials, err := parsePostgreSQLConnectionURL(pgurl) + if err != nil { return err } - rw.Release() - s.logger.Info().Msgf("backup created in %s", ctx.Param("dir")) + + cmd := exec.Command("which pg_dump") + fmt.Printf("cmd.Stdout: %v\n", cmd.Stdout) + fmt.Printf("cmd.Stderr: %v\n", cmd.Stderr) + + // cmd := exec.Command(fmt.Sprintf("PGPASSWORD=%s", credentials.password), "pg_dump", "-h", credentials.host, "-U", credentials.username, credentials.dbName) + // cmd.Stdout = file + // err = cmd.Run() + // if err != nil { + // return err + // } + return nil } + +type credentials struct { + username string + password string + host string + dbName string + sslMode string +} + +// validate a credentials object for connecting to postgres to create a backup +func parsePostgreSQLConnectionURL(connectionURL string) (*credentials, error) { + parsedURL, err := url.Parse(connectionURL) + if err != nil { + return nil, err + } + + if parsedURL.Scheme != "postgres" && parsedURL.Scheme != "postgresql" { + return nil, fmt.Errorf("failed to parse postgres URL") + } + + username := parsedURL.User.Username() + password, _ := parsedURL.User.Password() + host := parsedURL.Host + dbName := parsedURL.Path[1:] // Remove the leading slash + query := parsedURL.Query() + sslMode := query.Get("sslmode") + + return &credentials{ + username: username, + password: password, + host: host, + dbName: dbName, + sslMode: sslMode, + }, nil +} diff --git a/rpc/handlers_test.go b/rpc/handlers_test.go new file mode 100644 index 000000000..5e9922e1d --- /dev/null +++ b/rpc/handlers_test.go @@ -0,0 +1,56 @@ +package rpc + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v4" + "github.com/pokt-network/pocket/internal/testutil" + "github.com/pokt-network/pocket/logger" + "github.com/pokt-network/pocket/runtime/test_artifacts" + "github.com/pokt-network/pocket/shared/modules" +) + +func Test_RPCPostV1NodeBackup(t *testing.T) { + tests := []struct { + name string + setup func(t *testing.T, e echo.Context) *rpcServer + wantErr bool + }{ + { + name: "should create a backup in the default directory", + setup: func(t *testing.T, e echo.Context) *rpcServer { + _, _, url := test_artifacts.SetupPostgresDocker() + pmod := testutil.NewTestPersistenceModule(t, url) + // context := testutil.NewTestPostgresContext(t, pmod, 0) + + s := &rpcServer{ + logger: *logger.Global.CreateLoggerForModule(modules.RPCModuleName), + } + + s.SetBus(pmod.GetBus()) + + return s + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // create a new echo Context for each test + tempDir := t.TempDir() + e := echo.New() + req := httptest.NewRequest(http.MethodPost, tempDir, nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + // pass the fresh context to setup + s := tt.setup(t, c) + + // call and assert + if err := s.PostV1NodeBackup(c); (err != nil) != tt.wantErr { + t.Errorf("rpcServer.PostV1NodeBackup() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} From 28dd2e12af0124f4ce5c726b326f5492c6e04534 Mon Sep 17 00:00:00 2001 From: shakezula Date: Fri, 4 Aug 2023 09:49:28 -0600 Subject: [PATCH 09/12] [fixup] moves archivable interface into persistence --- shared/modules/persistence_module.go | 6 ++++++ shared/modules/treestore_module.go | 5 ----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/shared/modules/persistence_module.go b/shared/modules/persistence_module.go index 38c7681ba..9eaeed626 100644 --- a/shared/modules/persistence_module.go +++ b/shared/modules/persistence_module.go @@ -276,3 +276,9 @@ type PersistenceLocalContext interface { // for the application associated with the session GetSessionTokensUsed(*coreTypes.Session) (*big.Int, error) } + +// Archivable defines a two function interface for backing up and loading an archive. +type Archivable interface { + Backup(dir string) error + Load(dir string) error +} diff --git a/shared/modules/treestore_module.go b/shared/modules/treestore_module.go index 7a1605bed..06629a4cf 100644 --- a/shared/modules/treestore_module.go +++ b/shared/modules/treestore_module.go @@ -44,8 +44,3 @@ type TreeStoreModule interface { // GetTreeHashes returns a map of tree names to their root hashes GetTreeHashes() map[string]string } - -type Archivable interface { - Backup(dir string) error - Load(dir string) error -} From bda535846997ddc37cc38acfcee16c41402c8a91 Mon Sep 17 00:00:00 2001 From: shakezula Date: Fri, 4 Aug 2023 10:29:38 -0600 Subject: [PATCH 10/12] [chore] adds persistence functions to testutil * adds test utilities for setting up persistence module mocks --- internal/testutil/trees.go | 124 +++++++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 internal/testutil/trees.go diff --git a/internal/testutil/trees.go b/internal/testutil/trees.go new file mode 100644 index 000000000..e67eb2cda --- /dev/null +++ b/internal/testutil/trees.go @@ -0,0 +1,124 @@ +package testutil + +import ( + "log" + "testing" + + "github.com/pokt-network/pocket/logger" + "github.com/pokt-network/pocket/persistence" + "github.com/pokt-network/pocket/persistence/trees" + "github.com/pokt-network/pocket/runtime" + "github.com/pokt-network/pocket/runtime/configs" + "github.com/pokt-network/pocket/runtime/test_artifacts" + "github.com/pokt-network/pocket/runtime/test_artifacts/keygen" + "github.com/pokt-network/pocket/shared/messaging" + "github.com/pokt-network/pocket/shared/modules" + + "github.com/stretchr/testify/require" +) + +var ( + testSchema = "test_schema" + + genesisStateNumValidators = 5 + genesisStateNumServicers = 1 + genesisStateNumApplications = 1 +) + +// creates a new tree store with a tmp directory for nodestore persistence +// and then starts the tree store and returns its pointer. +func NewTestTreeStoreSubmodule(t *testing.T, bus modules.Bus) modules.TreeStoreModule { + t.Helper() + + tmpDir := t.TempDir() + ts, err := trees.Create( + bus, + trees.WithTreeStoreDirectory(tmpDir), + trees.WithLogger(logger.Global.CreateLoggerForModule(modules.TreeStoreSubmoduleName))) + require.NoError(t, err) + + err = ts.Start() + require.NoError(t, err) + + t.Cleanup(func() { + err := ts.Stop() + require.NoError(t, err) + }) + + return ts +} + +func SeedTestTreeStoreSubmodule(t *testing.T, mod modules.TreeStoreModule) modules.TreeStoreModule { + // TODO insert transaction data into postgres + // TODO trigger an update with a pgx connection + return mod +} + +func NewTestPersistenceModule(t *testing.T, databaseUrl string) modules.PersistenceModule { + teardownDeterministicKeygen := keygen.GetInstance().SetSeed(42) + defer teardownDeterministicKeygen() + + cfg := &configs.Config{ + Persistence: &configs.PersistenceConfig{ + PostgresUrl: databaseUrl, + NodeSchema: testSchema, + BlockStorePath: ":memory:", + TxIndexerPath: ":memory:", + TreesStoreDir: ":memory:", + MaxConnsCount: 5, + MinConnsCount: 1, + MaxConnLifetime: "5m", + MaxConnIdleTime: "1m", + HealthCheckPeriod: "30s", + }, + } + + genesisState, _ := test_artifacts.NewGenesisState( + genesisStateNumValidators, + genesisStateNumServicers, + genesisStateNumApplications, + genesisStateNumServicers, + ) + + runtimeMgr := runtime.NewManager(cfg, genesisState) + bus, err := runtime.CreateBus(runtimeMgr) + require.NoError(t, err) + + persistenceMod, err := persistence.Create(bus) + require.NoError(t, err) + + return persistenceMod.(modules.PersistenceModule) +} + +func NewTestPostgresContext(t testing.TB, pmod modules.PersistenceModule, height int64) *persistence.PostgresContext { + rwCtx, err := pmod.NewRWContext(height) + if err != nil { + log.Fatalf("Error creating new context: %v\n", err) + } + + postgresCtx, ok := rwCtx.(*persistence.PostgresContext) + if !ok { + log.Fatalf("Error casting RW context to Postgres context") + } + + // TECHDEBT: This should not be part of `NewTestPostgresContext`. It causes unnecessary resets + // if we call `NewTestPostgresContext` more than once in a single test. + t.Cleanup(func() { + resetStateToGenesis(pmod) + }) + + return postgresCtx +} + +// This is necessary for unit tests that are dependant on a baseline genesis state +func resetStateToGenesis(pmod modules.PersistenceModule) { + if err := pmod.ReleaseWriteContext(); err != nil { + log.Fatalf("Error releasing write context: %v\n", err) + } + if err := pmod.HandleDebugMessage(&messaging.DebugMessage{ + Action: messaging.DebugMessageAction_DEBUG_PERSISTENCE_RESET_TO_GENESIS, + Message: nil, + }); err != nil { + log.Fatalf("Error clearing state: %v\n", err) + } +} From 9a6b89dfe8f321a121745492d2f8487ba1529f28 Mon Sep 17 00:00:00 2001 From: shakezula Date: Mon, 7 Aug 2023 16:22:12 -0600 Subject: [PATCH 11/12] [wip] gets save rpc test working --- persistence/blockstore/block_store.go | 8 ++- persistence/kvstore/kvstore.go | 3 +- rpc/handlers_node.go | 90 +++------------------------ rpc/handlers_test.go | 66 +++++++++++++++++--- 4 files changed, 74 insertions(+), 93 deletions(-) diff --git a/persistence/blockstore/block_store.go b/persistence/blockstore/block_store.go index 9bc4aa5a4..9eafdaac9 100644 --- a/persistence/blockstore/block_store.go +++ b/persistence/blockstore/block_store.go @@ -4,6 +4,7 @@ package blockstore import ( "fmt" + "path/filepath" "github.com/pokt-network/pocket/persistence/kvstore" "github.com/pokt-network/pocket/shared/codec" @@ -11,6 +12,9 @@ import ( "github.com/pokt-network/pocket/shared/utils" ) +// backupName is the name of the archive file that is created when Backup is called for a BlockStore +const backupName = "blockstore.bak" + // BlockStore is a key-value store that maps block heights to serialized // block structures. // * It manages the atomic state transitions for applying a Unit of Work. @@ -93,8 +97,8 @@ func (bs *blockStore) Stop() error { return bs.kv.Stop() } -func (bs *blockStore) Backup(path string) error { - return bs.kv.Backup(path) +func (bs *blockStore) Backup(dir string) error { + return bs.kv.Backup(filepath.Join(dir, backupName)) } /////////////// diff --git a/persistence/kvstore/kvstore.go b/persistence/kvstore/kvstore.go index f4ea132d2..b25a35a22 100644 --- a/persistence/kvstore/kvstore.go +++ b/persistence/kvstore/kvstore.go @@ -26,7 +26,8 @@ type KVStore interface { Exists(key []byte) (bool, error) ClearAll() error - Backup(filepath string) error + // Backup takes a directory and makes a backup of the KVStore in that directory. + Backup(dir string) error } const ( diff --git a/rpc/handlers_node.go b/rpc/handlers_node.go index f7463adab..581f87cb2 100644 --- a/rpc/handlers_node.go +++ b/rpc/handlers_node.go @@ -1,106 +1,30 @@ package rpc import ( - "fmt" - "net/url" - "os" - "os/exec" - "path/filepath" - "time" - "github.com/labstack/echo/v4" - "github.com/pokt-network/pocket/runtime/configs" ) // PostV1NodeBackup triggers a backup of the TreeStore, the BlockStore, the PostgreSQL database. // TECHDEBT: Run each backup process in a goroutine to as elapsed time will become significant // with the current waterfall approach when even a moderate amount of data resides in each store. func (s *rpcServer) PostV1NodeBackup(ctx echo.Context) error { - dir := os.TempDir() // TODO_IN_THIS_COMMIT give this a sane default and make it configurable + // TECHDEBT: Wire this up to a default config param if dir == "" + // cfg := s.GetBus().GetRuntimeMgr().GetConfig() + + dir := ctx.Param("dir") + s.logger.Info().Msgf("creating backup in %s", dir) // backup the TreeStore - trees := s.GetBus().GetTreeStore() - if err := trees.Backup(dir); err != nil { + if err := s.GetBus().GetTreeStore().Backup(dir); err != nil { return err } // backup the BlockStore - path := fmt.Sprintf("%s-blockstore-backup.sql", time.Now().String()) - if err := s.GetBus().GetPersistenceModule().GetBlockStore().Backup(path); err != nil { - return err - } - - // backup the Postgres database - cfg := s.GetBus().GetRuntimeMgr().GetConfig() - err := postgresBackup(cfg, dir) // TODO_IN_THIS_COMMIT make this point at the right directory per the tests - if err != nil { + if err := s.GetBus().GetPersistenceModule().GetBlockStore().Backup(dir); err != nil { return err } s.logger.Info().Msgf("backup created in %s", dir) return nil } - -func postgresBackup(cfg *configs.Config, dir string) error { - filename := fmt.Sprintf("%s-postgres-backup.sql", time.Now().String()) - file, err := os.Create(filepath.Join(dir, filename)) - if err != nil { - return err - } - defer file.Close() - - // pgurl := cfg.Persistence.PostgresUrl - // credentials, err := parsePostgreSQLConnectionURL(pgurl) - if err != nil { - return err - } - - cmd := exec.Command("which pg_dump") - fmt.Printf("cmd.Stdout: %v\n", cmd.Stdout) - fmt.Printf("cmd.Stderr: %v\n", cmd.Stderr) - - // cmd := exec.Command(fmt.Sprintf("PGPASSWORD=%s", credentials.password), "pg_dump", "-h", credentials.host, "-U", credentials.username, credentials.dbName) - // cmd.Stdout = file - // err = cmd.Run() - // if err != nil { - // return err - // } - - return nil -} - -type credentials struct { - username string - password string - host string - dbName string - sslMode string -} - -// validate a credentials object for connecting to postgres to create a backup -func parsePostgreSQLConnectionURL(connectionURL string) (*credentials, error) { - parsedURL, err := url.Parse(connectionURL) - if err != nil { - return nil, err - } - - if parsedURL.Scheme != "postgres" && parsedURL.Scheme != "postgresql" { - return nil, fmt.Errorf("failed to parse postgres URL") - } - - username := parsedURL.User.Username() - password, _ := parsedURL.User.Password() - host := parsedURL.Host - dbName := parsedURL.Path[1:] // Remove the leading slash - query := parsedURL.Query() - sslMode := query.Get("sslmode") - - return &credentials{ - username: username, - password: password, - host: host, - dbName: dbName, - sslMode: sslMode, - }, nil -} diff --git a/rpc/handlers_test.go b/rpc/handlers_test.go index 5e9922e1d..020e24296 100644 --- a/rpc/handlers_test.go +++ b/rpc/handlers_test.go @@ -1,29 +1,39 @@ package rpc import ( + "io" "net/http" "net/http/httptest" + "os" + "path/filepath" "testing" - "github.com/labstack/echo/v4" "github.com/pokt-network/pocket/internal/testutil" "github.com/pokt-network/pocket/logger" "github.com/pokt-network/pocket/runtime/test_artifacts" "github.com/pokt-network/pocket/shared/modules" + "github.com/stretchr/testify/require" + + "github.com/labstack/echo/v4" ) func Test_RPCPostV1NodeBackup(t *testing.T) { - tests := []struct { + // THIS WORKS BUT ADJUST LATER + type testCase struct { name string setup func(t *testing.T, e echo.Context) *rpcServer + assert func(t *testing.T, tt testCase, e echo.Context, s *rpcServer) wantErr bool - }{ + } + + var testDir = t.TempDir() + + tests := []testCase{ { - name: "should create a backup in the default directory", + name: "should create a backup in the specified directory", setup: func(t *testing.T, e echo.Context) *rpcServer { _, _, url := test_artifacts.SetupPostgresDocker() pmod := testutil.NewTestPersistenceModule(t, url) - // context := testutil.NewTestPostgresContext(t, pmod, 0) s := &rpcServer{ logger: *logger.Global.CreateLoggerForModule(modules.RPCModuleName), @@ -31,16 +41,42 @@ func Test_RPCPostV1NodeBackup(t *testing.T) { s.SetBus(pmod.GetBus()) + e.SetParamNames("dir") + e.SetParamValues(testDir) + return s }, + wantErr: false, + assert: func(t *testing.T, tt testCase, e echo.Context, s *rpcServer) { + empty, err := isEmpty(testDir) + require.NoError(t, err) + require.False(t, empty) + f, err := os.Open(testDir) + require.NoError(t, err) + dirs, err := f.ReadDir(-1) + require.NoError(t, err) + require.True(t, len(dirs) == 12) + + // assert worldstate json was written + _, err = os.Open(filepath.Join(testDir, "worldstate.json")) + require.NoError(t, err) + + // assert blockstore was written + _, err = os.Open(filepath.Join(testDir, "blockstore.bak")) + require.NoError(t, err) + + // cleanup the directory after each test + t.Cleanup(func() { + require.NoError(t, os.RemoveAll(testDir)) + }) + }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { // create a new echo Context for each test - tempDir := t.TempDir() e := echo.New() - req := httptest.NewRequest(http.MethodPost, tempDir, nil) + req := httptest.NewRequest(http.MethodPost, "/v1/node/backup", nil) rec := httptest.NewRecorder() c := e.NewContext(req, rec) @@ -51,6 +87,22 @@ func Test_RPCPostV1NodeBackup(t *testing.T) { if err := s.PostV1NodeBackup(c); (err != nil) != tt.wantErr { t.Errorf("rpcServer.PostV1NodeBackup() error = %v, wantErr %v", err, tt.wantErr) } + tt.assert(t, tt, c, s) }) } } + +// TECHDEBT(#796) - Organize and dedupe this function into testutil package +func isEmpty(dir string) (bool, error) { + f, err := os.Open(dir) + if err != nil { + return false, err + } + defer f.Close() + + _, err = f.Readdirnames(1) // Or f.Readdir(1) + if err == io.EOF { + return true, nil + } + return false, err // Either not empty or error, suits both cases +} From 9c75f0ea1777ef0e8411105f29c400c18b43ca75 Mon Sep 17 00:00:00 2001 From: shakezula Date: Tue, 8 Aug 2023 14:33:12 -0600 Subject: [PATCH 12/12] [wip] adds error path test for backup endpoint --- persistence/trees/trees.go | 3 +- rpc/handlers_node.go | 15 +++++++-- rpc/handlers_test.go | 48 +++++++++++++--------------- shared/modules/persistence_module.go | 4 +-- 4 files changed, 38 insertions(+), 32 deletions(-) diff --git a/persistence/trees/trees.go b/persistence/trees/trees.go index d321bc705..ed2701391 100644 --- a/persistence/trees/trees.go +++ b/persistence/trees/trees.go @@ -372,7 +372,8 @@ func (t *treeStore) Load(dir string) error { nodeStore: nodeStore, } - // import merkle tree roots trees from worldState + // import merkle trees with the proper hash + t.merkleTrees = make(map[string]*stateTree) for treeName, treeRootHash := range w.MerkleRoots { treePath := fmt.Sprintf("%s/%s_nodes", dir, treeName) nodeStore, err := kvstore.NewKVStore(treePath) diff --git a/rpc/handlers_node.go b/rpc/handlers_node.go index 581f87cb2..da878ad09 100644 --- a/rpc/handlers_node.go +++ b/rpc/handlers_node.go @@ -1,6 +1,8 @@ package rpc import ( + "fmt" + "github.com/labstack/echo/v4" ) @@ -8,10 +10,12 @@ import ( // TECHDEBT: Run each backup process in a goroutine to as elapsed time will become significant // with the current waterfall approach when even a moderate amount of data resides in each store. func (s *rpcServer) PostV1NodeBackup(ctx echo.Context) error { - // TECHDEBT: Wire this up to a default config param if dir == "" - // cfg := s.GetBus().GetRuntimeMgr().GetConfig() - dir := ctx.Param("dir") + if dir == "" { + // TECHDEBT: Wire this up to a config param with a sane default + // cfg := s.GetBus().GetRuntimeMgr().GetConfig() + return fmt.Errorf("must specify a target backup directory") + } s.logger.Info().Msgf("creating backup in %s", dir) @@ -25,6 +29,11 @@ func (s *rpcServer) PostV1NodeBackup(ctx echo.Context) error { return err } + // TECHDEBT: backup Postgres + // if err := s.GetBus().GetPersistenceModule().Backup(dir); err != nil { + // return err + // } + s.logger.Info().Msgf("backup created in %s", dir) return nil } diff --git a/rpc/handlers_test.go b/rpc/handlers_test.go index 020e24296..96383ee47 100644 --- a/rpc/handlers_test.go +++ b/rpc/handlers_test.go @@ -1,7 +1,6 @@ package rpc import ( - "io" "net/http" "net/http/httptest" "os" @@ -18,7 +17,6 @@ import ( ) func Test_RPCPostV1NodeBackup(t *testing.T) { - // THIS WORKS BUT ADJUST LATER type testCase struct { name string setup func(t *testing.T, e echo.Context) *rpcServer @@ -26,6 +24,7 @@ func Test_RPCPostV1NodeBackup(t *testing.T) { wantErr bool } + // NB: testDir is used and cleared by each test case var testDir = t.TempDir() tests := []testCase{ @@ -48,16 +47,14 @@ func Test_RPCPostV1NodeBackup(t *testing.T) { }, wantErr: false, assert: func(t *testing.T, tt testCase, e echo.Context, s *rpcServer) { - empty, err := isEmpty(testDir) - require.NoError(t, err) - require.False(t, empty) f, err := os.Open(testDir) require.NoError(t, err) dirs, err := f.ReadDir(-1) require.NoError(t, err) + // assert that we wrote the expected 12 files into this directory require.True(t, len(dirs) == 12) - // assert worldstate json was written + // assert worldstate.json was written _, err = os.Open(filepath.Join(testDir, "worldstate.json")) require.NoError(t, err) @@ -71,38 +68,37 @@ func Test_RPCPostV1NodeBackup(t *testing.T) { }) }, }, + { + name: "should error if no directory specified", + setup: func(t *testing.T, e echo.Context) *rpcServer { + _, _, url := test_artifacts.SetupPostgresDocker() + pmod := testutil.NewTestPersistenceModule(t, url) + + s := &rpcServer{ + logger: *logger.Global.CreateLoggerForModule(modules.RPCModuleName), + } + + s.SetBus(pmod.GetBus()) + + return s + }, + wantErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - // create a new echo Context for each test e := echo.New() req := httptest.NewRequest(http.MethodPost, "/v1/node/backup", nil) rec := httptest.NewRecorder() c := e.NewContext(req, rec) - - // pass the fresh context to setup s := tt.setup(t, c) - // call and assert if err := s.PostV1NodeBackup(c); (err != nil) != tt.wantErr { t.Errorf("rpcServer.PostV1NodeBackup() error = %v, wantErr %v", err, tt.wantErr) } - tt.assert(t, tt, c, s) + if tt.assert != nil { + tt.assert(t, tt, c, s) + } }) } } - -// TECHDEBT(#796) - Organize and dedupe this function into testutil package -func isEmpty(dir string) (bool, error) { - f, err := os.Open(dir) - if err != nil { - return false, err - } - defer f.Close() - - _, err = f.Readdirnames(1) // Or f.Readdir(1) - if err == io.EOF { - return true, nil - } - return false, err // Either not empty or error, suits both cases -} diff --git a/shared/modules/persistence_module.go b/shared/modules/persistence_module.go index 9eaeed626..3bb60cc8a 100644 --- a/shared/modules/persistence_module.go +++ b/shared/modules/persistence_module.go @@ -277,8 +277,8 @@ type PersistenceLocalContext interface { GetSessionTokensUsed(*coreTypes.Session) (*big.Int, error) } -// Archivable defines a two function interface for backing up and loading an archive. +// Archivable defines a single function interface for creating a reusable archive of a module. type Archivable interface { + // Backup creates an archive for the module in the given directory and returns an error if anything went wrong. Backup(dir string) error - Load(dir string) error }