Skip to content
Open
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
184 changes: 184 additions & 0 deletions report.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
package main

import (
"compress/gzip"
"context"
"encoding/json"
"fmt"
"io"
"os"
"sort"
"strings"

"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/urfave/cli/v3"

seis3 "github.com/sei-protocol/seictl/sidecar/s3"
"github.com/sei-protocol/seictl/sidecar/shadow"
"github.com/sei-protocol/seictl/sidecar/tasks"
)

var reportCmd = cli.Command{
Name: "report",
Usage: "Analyze shadow chain comparison data",
Commands: []*cli.Command{
&reportDivergenceCmd,
&reportDigestCmd,
&reportListCmd,
},
}
Expand Down Expand Up @@ -114,6 +121,183 @@ func runReportDivergence(ctx context.Context, cmd *cli.Command) error {
return nil
}

var reportDigestCmd = cli.Command{
Name: "digest",
Usage: "Fetch and render an evm-logical-digest record from S3",
Flags: []cli.Flag{
&cli.StringFlag{
Name: "env",
Usage: "Environment shorthand (expands to '{env}-sei-shadow-results')",
},
&cli.StringFlag{
Name: "bucket",
Sources: cli.EnvVars("SEI_RESULT_EXPORT_BUCKET"),
Usage: "S3 bucket containing the record",
},
&cli.StringFlag{
Name: "key",
Usage: "S3 object key (e.g. shadow-results/endpoint-digest-198740042-semantic.json.gz)",
},
&cli.IntFlag{
Name: "height",
Usage: "Block height of the digest record (used with --normalization, alternative to --key)",
},
&cli.StringFlag{
Name: "normalization",
Usage: "Memiavl normalization of the digest record (used with --height)",
},
&cli.StringFlag{
Name: "prefix",
Sources: cli.EnvVars("SEI_RESULT_EXPORT_PREFIX"),
Usage: "S3 key prefix (used with --height to compute key)",
Value: "shadow-results/",
},
&cli.StringFlag{
Name: "region",
Sources: cli.EnvVars("SEI_RESULT_EXPORT_REGION"),
Usage: "AWS region",
Value: "eu-central-1",
},
&cli.BoolFlag{
Name: "json",
Usage: "Output raw JSON instead of markdown",
},
},
Action: runReportDigest,
}

func runReportDigest(ctx context.Context, cmd *cli.Command) error {
if cmd.IsSet("key") && cmd.IsSet("height") {
return fmt.Errorf("--key and --height are mutually exclusive")
}

bucket := cmd.String("bucket")
key := cmd.String("key")
region := cmd.String("region")
prefix := cmd.String("prefix")

if cmd.IsSet("env") || cmd.IsSet("height") {
resolved, resolvedPrefix, resolvedRegion, err := resolveS3Ref(
cmd.String("env"), bucket, prefix, region,
)
if err != nil {
return err
}
bucket = resolved
region = resolvedRegion
if cmd.IsSet("height") {
if !cmd.IsSet("normalization") {
return fmt.Errorf("--normalization is required with --height")
}
key = fmt.Sprintf("%sendpoint-digest-%d-%s.json.gz",
resolvedPrefix, cmd.Int("height"), cmd.String("normalization"))
}
}

if bucket == "" {
return fmt.Errorf("one of --env or --bucket is required")
}
if key == "" {
return fmt.Errorf("one of --key or --height is required")
}

downloader, err := seis3.DefaultDownloaderFactory(ctx, region)
if err != nil {
return err
}

record, err := fetchDigestRecord(ctx, downloader, bucket, key)
if err != nil {
return err
}

if cmd.Bool("json") {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
return enc.Encode(record)
}

fmt.Print(renderDigestMarkdown(record))
return nil
}

// fetchDigestRecord downloads and decodes an EndpointDigestRecord from S3,
// gunzipping when the key is .gz — mirroring shadow.FetchReport for the digest
// artifact the evm-logical-digest task publishes.
func fetchDigestRecord(ctx context.Context, downloader seis3.Downloader, bucket, key string) (*tasks.EndpointDigestRecord, error) {
resp, err := downloader.GetObject(ctx, &s3.GetObjectInput{
Bucket: &bucket,
Key: &key,
})
if err != nil {
return nil, fmt.Errorf("downloading s3://%s/%s: %w", bucket, key, err)
}
defer resp.Body.Close()

var reader io.Reader = resp.Body
if strings.HasSuffix(key, ".gz") {
gz, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("decompressing digest record: %w", err)
}
defer gz.Close()
reader = gz
}

var record tasks.EndpointDigestRecord
if err := json.NewDecoder(reader).Decode(&record); err != nil {
return nil, fmt.Errorf("decoding digest record: %w", err)
}
return &record, nil
}

// renderDigestMarkdown produces a human-readable view of an EndpointDigestRecord
// in the same shape as the divergence report renderer: header, overall verdict,
// per-bucket flatkv/memiavl digests, and the axes the digest proves.
func renderDigestMarkdown(r *tasks.EndpointDigestRecord) string {
var b strings.Builder

fmt.Fprintf(&b, "# EVM Logical Digest — Height %d (%s)\n\n", r.Height, r.Normalization)
fmt.Fprintf(&b, "**Generated at:** %s\n\n", r.GeneratedAt)
fmt.Fprintf(&b, "**Overall match:** %s\n\n", matchIcon(r.Match))
fmt.Fprintf(&b, "| Backend | Final Digest |\n")
fmt.Fprintf(&b, "|---------|--------------|\n")
fmt.Fprintf(&b, "| flatkv | %s |\n", truncateDigest(r.FlatKVDigest))
fmt.Fprintf(&b, "| memiavl | %s |\n\n", truncateDigest(r.MemIAVLDigest))

fmt.Fprintf(&b, "## Per-Bucket Digests\n\n")
fmt.Fprintf(&b, "| Bucket | FlatKV | MemIAVL | Match |\n")
fmt.Fprintf(&b, "|--------|--------|---------|-------|\n")
for _, name := range []string{"account", "code", "storage", "legacy"} {
bkt, ok := r.PerBucket[name]
if !ok {
continue
}
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n",
name, truncateDigest(bkt.FlatKV), truncateDigest(bkt.MemIAVL), matchIcon(bkt.Match))
}
fmt.Fprintf(&b, "\n")

axes := append([]string(nil), r.AxesProved...)
sort.Strings(axes)
fmt.Fprintf(&b, "**Axes proved:** %s\n", strings.Join(axes, ", "))
return b.String()
}

func matchIcon(match bool) string {
if match {
return "✅"
}
return "❌"
}

func truncateDigest(h string) string {
if len(h) <= 16 {
return h
}
return h[:8] + "..." + h[len(h)-4:]
}

// resolveS3Ref converts --env/--bucket/--prefix/--region flags to concrete values.
func resolveS3Ref(env, bucket, prefix, region string) (string, string, string, error) {
switch {
Expand Down
31 changes: 28 additions & 3 deletions report_list.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
var (
comparePageRe = regexp.MustCompile(`(\d+)-(\d+)\.compare\.ndjson\.gz$`)
divergenceReportRe = regexp.MustCompile(`divergence-(\d+)\.report\.json\.gz$`)
endpointDigestRe = regexp.MustCompile(`endpoint-digest-(\d+)-(\w+)\.json\.gz$`)
)

var reportListCmd = cli.Command{
Expand Down Expand Up @@ -58,6 +59,7 @@ var reportListCmd = cli.Command{
type listOutput struct {
Pages []pageEntry `json:"pages"`
DivergenceReports []divergenceEntry `json:"divergenceReports"`
DigestRecords []digestEntry `json:"digestRecords"`
TotalBlocks int64 `json:"totalBlocks"`
}

Expand All @@ -73,6 +75,12 @@ type divergenceEntry struct {
Height int64 `json:"height"`
}

type digestEntry struct {
Key string `json:"key"`
Height int64 `json:"height"`
Normalization string `json:"normalization"`
}

func runReportList(ctx context.Context, cmd *cli.Command) error {
bucket, prefix, region, err := resolveS3Ref(
cmd.String("env"), cmd.String("bucket"), cmd.String("prefix"), cmd.String("region"),
Expand All @@ -94,6 +102,7 @@ func runReportList(ctx context.Context, cmd *cli.Command) error {

var pages []pageEntry
var reports []divergenceEntry
var digests []digestEntry

for {
resp, err := lister.ListObjectsV2(ctx, input)
Expand All @@ -112,6 +121,9 @@ func runReportList(ctx context.Context, cmd *cli.Command) error {
} else if m := divergenceReportRe.FindStringSubmatch(key); len(m) >= 2 {
height, _ := strconv.ParseInt(m[1], 10, 64)
reports = append(reports, divergenceEntry{Key: key, Height: height})
} else if m := endpointDigestRe.FindStringSubmatch(key); len(m) >= 3 {
height, _ := strconv.ParseInt(m[1], 10, 64)
digests = append(digests, digestEntry{Key: key, Height: height, Normalization: m[2]})
}
}
if !aws.ToBool(resp.IsTruncated) {
Expand All @@ -122,6 +134,12 @@ func runReportList(ctx context.Context, cmd *cli.Command) error {

sort.Slice(pages, func(i, j int) bool { return pages[i].StartHeight < pages[j].StartHeight })
sort.Slice(reports, func(i, j int) bool { return reports[i].Height < reports[j].Height })
sort.Slice(digests, func(i, j int) bool {
if digests[i].Height != digests[j].Height {
return digests[i].Height < digests[j].Height
}
return digests[i].Normalization < digests[j].Normalization
})

var totalBlocks int64
for _, p := range pages {
Expand All @@ -131,6 +149,7 @@ func runReportList(ctx context.Context, cmd *cli.Command) error {
out := listOutput{
Pages: pages,
DivergenceReports: reports,
DigestRecords: digests,
TotalBlocks: totalBlocks,
}
if out.Pages == nil {
Expand All @@ -139,6 +158,9 @@ func runReportList(ctx context.Context, cmd *cli.Command) error {
if out.DivergenceReports == nil {
out.DivergenceReports = []divergenceEntry{}
}
if out.DigestRecords == nil {
out.DigestRecords = []digestEntry{}
}

if cmd.Bool("json") {
enc := json.NewEncoder(os.Stdout)
Expand All @@ -147,10 +169,10 @@ func runReportList(ctx context.Context, cmd *cli.Command) error {
}

// Human-readable output.
fmt.Fprintf(os.Stderr, "%d comparison page(s), %d divergence report(s), %d blocks covered\n\n",
len(pages), len(reports), totalBlocks)
fmt.Fprintf(os.Stderr, "%d comparison page(s), %d divergence report(s), %d digest record(s), %d blocks covered\n\n",
len(pages), len(reports), len(digests), totalBlocks)

if len(pages) == 0 && len(reports) == 0 {
if len(pages) == 0 && len(reports) == 0 && len(digests) == 0 {
fmt.Fprintln(os.Stderr, "no data found")
return nil
}
Expand All @@ -163,6 +185,9 @@ func runReportList(ctx context.Context, cmd *cli.Command) error {
for _, r := range reports {
fmt.Fprintf(w, "divergence\t%d\t-\n", r.Height)
}
for _, d := range digests {
fmt.Fprintf(w, "digest (%s)\t%d\t-\n", d.Normalization, d.Height)
}
w.Flush()
return nil
}
57 changes: 57 additions & 0 deletions report_list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,63 @@ func TestComparePageRe(t *testing.T) {
}
}

func TestEndpointDigestRe(t *testing.T) {
tests := []struct {
key string
wantH string
wantNorm string
want bool
}{
{
key: "shadow-results/endpoint-digest-198032451-semantic.json.gz",
wantH: "198032451",
wantNorm: "semantic",
want: true,
},
{
key: "prefix/endpoint-digest-1-translator.json.gz",
wantH: "1",
wantNorm: "translator",
want: true,
},
{
// Divergence report — must NOT match.
key: "shadow-results/divergence-198032451.report.json.gz",
want: false,
},
{
// Comparison page — must NOT match.
key: "shadow-results/198000000-198000099.compare.ndjson.gz",
want: false,
},
{
key: "",
want: false,
},
}

for _, tt := range tests {
t.Run(tt.key, func(t *testing.T) {
m := endpointDigestRe.FindStringSubmatch(tt.key)
if !tt.want {
if len(m) >= 3 {
t.Fatalf("expected no match for %q, got %v", tt.key, m)
}
return
}
if len(m) < 3 {
t.Fatalf("expected match for %q, got nil", tt.key)
}
if m[1] != tt.wantH {
t.Errorf("height = %q, want %q", m[1], tt.wantH)
}
if m[2] != tt.wantNorm {
t.Errorf("normalization = %q, want %q", m[2], tt.wantNorm)
}
})
}
}

func TestDivergenceReportRe(t *testing.T) {
tests := []struct {
key string
Expand Down
Loading
Loading