Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/evm/single/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ func main() {
rollcmd.VersionCmd,
rollcmd.NetInfoCmd,
rollcmd.StoreUnsafeCleanCmd,
rollcmd.StoreP2PInspectCmd,
rollcmd.KeysCmd(),
)

Expand Down
1 change: 1 addition & 0 deletions apps/grpc/single/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ the Evolve execution gRPC interface.`,
evcmd.VersionCmd,
evcmd.NetInfoCmd,
evcmd.StoreUnsafeCleanCmd,
evcmd.StoreP2PInspectCmd,
evcmd.KeysCmd(),
)

Expand Down
1 change: 1 addition & 0 deletions apps/testapp/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ func main() {
rollcmd.VersionCmd,
rollcmd.NetInfoCmd,
rollcmd.StoreUnsafeCleanCmd,
rollcmd.StoreP2PInspectCmd,
rollcmd.KeysCmd(),
cmds.NewRollbackCmd(),
initCmd,
Expand Down
200 changes: 200 additions & 0 deletions pkg/cmd/store.go
Original file line number Diff line number Diff line change
@@ -1,11 +1,23 @@
package cmd

import (
"context"
"errors"
"fmt"
"os"
"path/filepath"
"time"

goheader "github.com/celestiaorg/go-header"
goheaderstore "github.com/celestiaorg/go-header/store"
ds "github.com/ipfs/go-datastore"
kt "github.com/ipfs/go-datastore/keytransform"
"github.com/spf13/cobra"

"github.com/evstack/ev-node/node"
"github.com/evstack/ev-node/pkg/config"
"github.com/evstack/ev-node/pkg/store"
"github.com/evstack/ev-node/types"
)

// UnsafeCleanDataDir removes all contents of the specified data directory.
Expand Down Expand Up @@ -53,3 +65,191 @@ This operation is unsafe and cannot be undone. Use with caution!`,
return nil
},
}

// StoreP2PInspectCmd reports head/tail information for the go-header stores used by P2P sync.
var StoreP2PInspectCmd = &cobra.Command{
Use: "store-info",
Short: "Inspect the go-header (P2P) stores and display their tail/head entries",
Long: `Opens the datastore used by the node's go-header services and reports
the current height, head, and tail information for both the header and data stores.`,
RunE: func(cmd *cobra.Command, args []string) error {
nodeConfig, err := ParseConfig(cmd)
if err != nil {
return fmt.Errorf("error parsing config: %w", err)
}

ctx := cmd.Context()
if ctx == nil {
ctx = context.Background()
}

dbName := resolveDBName(cmd)

rawStore, err := store.NewDefaultKVStore(nodeConfig.RootDir, nodeConfig.DBPath, dbName)
if err != nil {
return fmt.Errorf("failed to open datastore: %w", err)
}
defer func() {
if closeErr := rawStore.Close(); closeErr != nil {
cmd.PrintErrf("warning: failed to close datastore: %v\n", closeErr)
}
}()

mainStore := kt.Wrap(rawStore, &kt.PrefixTransform{
Prefix: ds.NewKey(node.EvPrefix),
})

headerSnapshot, err := inspectP2PStore[*types.SignedHeader](ctx, mainStore, headerStorePrefix, "Header Store")
if err != nil {
return fmt.Errorf("failed to inspect header store: %w", err)
}

dataSnapshot, err := inspectP2PStore[*types.Data](ctx, mainStore, dataStorePrefix, "Data Store")
if err != nil {
return fmt.Errorf("failed to inspect data store: %w", err)
}

storePath := resolveStorePath(nodeConfig.RootDir, nodeConfig.DBPath, dbName)

out := cmd.OutOrStdout()
fmt.Fprintf(out, "Inspecting go-header stores at %s\n", storePath)
printP2PStoreSnapshot(cmd, headerSnapshot)
printP2PStoreSnapshot(cmd, dataSnapshot)

return nil
},
}

const (
headerStorePrefix = "headerSync"
dataStorePrefix = "dataSync"
)

type p2pStoreSnapshot struct {
Label string
Prefix string
Height uint64
HeadHeight uint64
HeadHash string
HeadTime time.Time
TailHeight uint64
TailHash string
TailTime time.Time
HeadPresent bool
TailPresent bool
Empty bool
}

func inspectP2PStore[H goheader.Header[H]](
ctx context.Context,
datastore ds.Batching,
prefix string,
label string,
) (p2pStoreSnapshot, error) {
storeImpl, err := goheaderstore.NewStore[H](
datastore,
goheaderstore.WithStorePrefix(prefix),
goheaderstore.WithMetrics(),
)
if err != nil {
return p2pStoreSnapshot{}, fmt.Errorf("failed to open %s: %w", label, err)
}

if err := storeImpl.Start(ctx); err != nil {
return p2pStoreSnapshot{}, fmt.Errorf("failed to start %s: %w", label, err)
}
defer func() {
_ = storeImpl.Stop(context.Background())
}()

snapshot := p2pStoreSnapshot{
Label: label,
Prefix: prefix,
Height: storeImpl.Height(),
}

if err := populateSnapshot(ctx, storeImpl, &snapshot); err != nil {
return p2pStoreSnapshot{}, err
}

return snapshot, nil
}

func populateSnapshot[H goheader.Header[H]](
ctx context.Context,
storeImpl *goheaderstore.Store[H],
snapshot *p2pStoreSnapshot,
) error {
head, err := storeImpl.Head(ctx)
switch {
case err == nil:
snapshot.HeadPresent = true
snapshot.HeadHeight = head.Height()
snapshot.HeadHash = head.Hash().String()
snapshot.HeadTime = head.Time()
case errors.Is(err, goheader.ErrEmptyStore), errors.Is(err, goheader.ErrNotFound):
// store not initialized yet
default:
return fmt.Errorf("failed to read %s head: %w", snapshot.Label, err)
}

tail, err := storeImpl.Tail(ctx)
switch {
case err == nil:
snapshot.TailPresent = true
snapshot.TailHeight = tail.Height()
snapshot.TailHash = tail.Hash().String()
snapshot.TailTime = tail.Time()
case errors.Is(err, goheader.ErrEmptyStore), errors.Is(err, goheader.ErrNotFound):
default:
return fmt.Errorf("failed to read %s tail: %w", snapshot.Label, err)
}

snapshot.Empty = !(snapshot.HeadPresent || snapshot.TailPresent)

return nil
}

func printP2PStoreSnapshot(cmd *cobra.Command, snapshot p2pStoreSnapshot) {
out := cmd.OutOrStdout()
fmt.Fprintf(out, "\n[%s]\n", snapshot.Label)
fmt.Fprintf(out, "prefix: %s\n", snapshot.Prefix)
fmt.Fprintf(out, "height: %d\n", snapshot.Height)
if snapshot.Empty {
fmt.Fprintln(out, "status: empty (no entries found)")
return
}

if snapshot.TailPresent {
fmt.Fprintf(out, "tail: height=%d hash=%s%s\n", snapshot.TailHeight, snapshot.TailHash, formatTime(snapshot.TailTime))
}
if snapshot.HeadPresent {
fmt.Fprintf(out, "head: height=%d hash=%s%s\n", snapshot.HeadHeight, snapshot.HeadHash, formatTime(snapshot.HeadTime))
}
}

func formatTime(t time.Time) string {
if t.IsZero() {
return ""
}
return fmt.Sprintf(" time=%s", t.UTC().Format(time.RFC3339))
}

func resolveDBName(cmd *cobra.Command) string {
if cmd == nil {
return config.ConfigFileName
}
root := cmd.Root()
if root == nil || root.Name() == "" {
return config.ConfigFileName
}
return root.Name()
}

func resolveStorePath(rootDir, dbPath, dbName string) string {
base := dbPath
if !filepath.IsAbs(dbPath) {
base = filepath.Join(rootDir, dbPath)
}
return filepath.Join(base, dbName)
}
96 changes: 96 additions & 0 deletions pkg/cmd/store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,25 @@ package cmd

import (
"bytes"
"context"
cryptoRand "crypto/rand"
"fmt"
"os"
"path/filepath"
"testing"

goheaderstore "github.com/celestiaorg/go-header/store"
ds "github.com/ipfs/go-datastore"
kt "github.com/ipfs/go-datastore/keytransform"
"github.com/libp2p/go-libp2p/core/crypto"
"github.com/spf13/cobra"
"github.com/stretchr/testify/require"

"github.com/evstack/ev-node/node"
"github.com/evstack/ev-node/pkg/config"
"github.com/evstack/ev-node/pkg/signer/noop"
"github.com/evstack/ev-node/pkg/store"
"github.com/evstack/ev-node/types"
)

func TestUnsafeCleanDataDir(t *testing.T) {
Expand Down Expand Up @@ -85,3 +97,87 @@ func TestStoreUnsafeCleanCmd(t *testing.T) {
// Check output message (optional)
require.Contains(t, buf.String(), fmt.Sprintf("All contents of the data directory at %s have been removed.", dataDir))
}

func TestStoreP2PInspectCmd(t *testing.T) {
tempDir := t.TempDir()
const appName = "testapp"

// Seed the header store with a couple of entries.
seedHeaderStore(t, tempDir, appName)

rootCmd := &cobra.Command{Use: appName}
rootCmd.PersistentFlags().String(config.FlagRootDir, tempDir, "root directory")
rootCmd.AddCommand(StoreP2PInspectCmd)

buf := new(bytes.Buffer)
rootCmd.SetOut(buf)
rootCmd.SetErr(buf)
rootCmd.SetArgs([]string{"store-info"})

err := rootCmd.Execute()
require.NoError(t, err)

output := buf.String()
require.Contains(t, output, "Inspecting go-header stores")
require.Contains(t, output, "[Header Store]")
require.Contains(t, output, "tail: height=1")
require.Contains(t, output, "head: height=2")
require.Contains(t, output, "[Data Store]")
require.Contains(t, output, "status: empty")
}

func seedHeaderStore(t *testing.T, rootDir, dbName string) {
t.Helper()

rawStore, err := store.NewDefaultKVStore(rootDir, "data", dbName)
require.NoError(t, err)

mainStore := kt.Wrap(rawStore, &kt.PrefixTransform{
Prefix: ds.NewKey(node.EvPrefix),
})

headerStore, err := goheaderstore.NewStore[*types.SignedHeader](
mainStore,
goheaderstore.WithStorePrefix(headerStorePrefix),
goheaderstore.WithMetrics(),
)
require.NoError(t, err)

ctx := context.Background()
require.NoError(t, headerStore.Start(ctx))

defer func() {
require.NoError(t, headerStore.Stop(ctx))
require.NoError(t, rawStore.Close())
}()

pk, _, err := crypto.GenerateEd25519Key(cryptoRand.Reader)
require.NoError(t, err)
noopSigner, err := noop.NewNoopSigner(pk)
require.NoError(t, err)

chainID := "test-chain"
headerCfg := types.HeaderConfig{
Height: 1,
DataHash: types.GetRandomBytes(32),
AppHash: types.GetRandomBytes(32),
Signer: noopSigner,
}

first, err := types.GetRandomSignedHeaderCustom(&headerCfg, chainID)
require.NoError(t, err)
require.NoError(t, headerStore.Append(ctx, first))

next := &types.SignedHeader{
Header: types.GetRandomNextHeader(first.Header, chainID),
Signer: first.Signer,
}
payload, err := next.Header.MarshalBinary()
require.NoError(t, err)
signature, err := noopSigner.Sign(payload)
require.NoError(t, err)
next.Signature = signature

require.NoError(t, headerStore.Append(ctx, next))
require.NoError(t, headerStore.Sync(ctx))
}
Loading