Skip to content

Commit 4fb3316

Browse files
bdchathamclaude
andauthored
feat: environment-driven snapshot restore with targetHeight selection (#51)
## Summary - Snapshot restore derives S3 bucket/region/chainID from environment (SEI_SNAPSHOT_BUCKET, SEI_SNAPSHOT_REGION, SEI_CHAIN_ID) - Only task param is now `targetHeight`: - `> 0`: lists S3 objects, picks highest snapshot <= target - `== 0`: reads latest.txt for newest snapshot - Simplified filename convention: `{height}.tar.gz` (removed chain/region suffix) - Added ObjectLister interface for S3 ListObjectsV2 pagination - serve.go validates snapshot env vars at startup ## Test plan - [x] All sidecar tests pass - [x] Target height selection tested (picks correct snapshot, errors on no match) - [x] Latest.txt path tested - [x] Client round-trip tests updated 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 7c11883 commit 4fb3316

9 files changed

Lines changed: 386 additions & 273 deletions

File tree

serve.go

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,11 +42,15 @@ var serveCmd = cli.Command{
4242
chainID := os.Getenv("SEI_CHAIN_ID")
4343
genesisBucket := os.Getenv("SEI_GENESIS_BUCKET")
4444
genesisRegion := os.Getenv("SEI_GENESIS_REGION")
45+
snapshotBucket := os.Getenv("SEI_SNAPSHOT_BUCKET")
46+
snapshotRegion := os.Getenv("SEI_SNAPSHOT_REGION")
4547

4648
for _, kv := range []struct{ name, val string }{
4749
{"SEI_CHAIN_ID", chainID},
4850
{"SEI_GENESIS_BUCKET", genesisBucket},
4951
{"SEI_GENESIS_REGION", genesisRegion},
52+
{"SEI_SNAPSHOT_BUCKET", snapshotBucket},
53+
{"SEI_SNAPSHOT_REGION", snapshotRegion},
5054
} {
5155
if kv.val == "" {
5256
return fmt.Errorf("required environment variable %s is not set", kv.name)
@@ -71,8 +75,13 @@ var serveCmd = cli.Command{
7175
return fmt.Errorf("open result store: %w", err)
7276
}
7377

78+
snapshotRestorer, err := tasks.NewSnapshotRestorer(homeDir, snapshotBucket, snapshotRegion, chainID, nil, nil)
79+
if err != nil {
80+
return fmt.Errorf("creating snapshot restorer: %w", err)
81+
}
82+
7483
handlers := map[engine.TaskType]engine.TaskHandler{
75-
engine.TaskSnapshotRestore: tasks.NewSnapshotRestorer(homeDir, nil).Handler(),
84+
engine.TaskSnapshotRestore: snapshotRestorer.Handler(),
7685
engine.TaskDiscoverPeers: tasks.NewPeerDiscoverer(homeDir, nil, nil).Handler(),
7786
engine.TaskConfigPatch: tasks.NewConfigPatcher(homeDir).Handler(),
7887
engine.TaskConfigApply: tasks.NewConfigApplier(homeDir).Handler(),

sidecar/client/client_test.go

Lines changed: 5 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -69,19 +69,16 @@ func TestSubmitTask_HTTP201(t *testing.T) {
6969
t.Fatal("Params is nil")
7070
}
7171
params := *req.Params
72-
if params["bucket"] != "my-bucket" {
73-
t.Errorf("bucket = %v, want my-bucket", params["bucket"])
72+
if params["targetHeight"] != float64(100000000) {
73+
t.Errorf("targetHeight = %v, want 100000000", params["targetHeight"])
7474
}
7575
w.Header().Set("Content-Type", "application/json")
7676
w.WriteHeader(http.StatusCreated)
7777
_ = json.NewEncoder(w).Encode(TaskSubmitResponse{Id: taskID})
7878
}))
7979

8080
task := SnapshotRestoreTask{
81-
Bucket: "my-bucket",
82-
Prefix: "snapshots",
83-
Region: "us-east-1",
84-
ChainID: "sei-chain",
81+
TargetHeight: 100000000,
8582
}
8683
id, err := c.SubmitSnapshotRestoreTask(context.Background(), task)
8784
if err != nil {
@@ -206,7 +203,8 @@ func TestSubmitTask_ValidationFailure(t *testing.T) {
206203
t.Fatal("server should not be called when validation fails")
207204
}))
208205

209-
_, err := c.SubmitSnapshotRestoreTask(context.Background(), SnapshotRestoreTask{})
206+
// SnapshotUploadTask requires Bucket — empty should fail validation.
207+
_, err := c.SubmitSnapshotUploadTask(context.Background(), SnapshotUploadTask{})
210208
if err == nil {
211209
t.Fatal("expected validation error")
212210
}

sidecar/client/tasks.go

Lines changed: 18 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -61,54 +61,40 @@ const (
6161
)
6262

6363
// SnapshotRestoreTask downloads and extracts a snapshot archive from S3.
64+
// S3 coordinates are derived by the sidecar from its environment.
65+
// TargetHeight selects the highest available snapshot <= that height.
66+
// When zero, the latest snapshot (from latest.txt) is used.
6467
type SnapshotRestoreTask struct {
6568
TaskMeta
66-
Bucket string
67-
Prefix string
68-
Region string
69-
ChainID string
69+
TargetHeight int64
7070
}
7171

7272
func (t SnapshotRestoreTask) TaskType() string { return TaskTypeSnapshotRestore }
7373

74-
func (t SnapshotRestoreTask) Validate() error {
75-
if t.Bucket == "" {
76-
return fmt.Errorf("snapshot-restore: missing required field Bucket")
77-
}
78-
if t.Prefix == "" {
79-
return fmt.Errorf("snapshot-restore: missing required field Prefix")
80-
}
81-
if t.Region == "" {
82-
return fmt.Errorf("snapshot-restore: missing required field Region")
83-
}
84-
if t.ChainID == "" {
85-
return fmt.Errorf("snapshot-restore: missing required field ChainID")
86-
}
87-
return nil
88-
}
74+
func (t SnapshotRestoreTask) Validate() error { return nil }
8975

9076
func (t SnapshotRestoreTask) ToTaskRequest() TaskRequest {
91-
p := map[string]interface{}{
92-
"bucket": t.Bucket,
93-
"prefix": t.Prefix,
94-
"region": t.Region,
95-
"chainId": t.ChainID,
77+
var p *map[string]interface{}
78+
if t.TargetHeight > 0 {
79+
m := map[string]interface{}{"targetHeight": t.TargetHeight}
80+
p = &m
9681
}
97-
req := TaskRequest{Type: t.TaskType(), Params: &p}
82+
req := TaskRequest{Type: t.TaskType(), Params: p}
9883
t.applyMeta(&req)
9984
return req
10085
}
10186

10287
// SnapshotRestoreTaskFromParams reconstructs a SnapshotRestoreTask from
10388
// a generic params map. Useful for round-trip testing.
10489
func SnapshotRestoreTaskFromParams(params map[string]interface{}) SnapshotRestoreTask {
105-
s := func(k string) string { v, _ := params[k].(string); return v }
106-
return SnapshotRestoreTask{
107-
Bucket: s("bucket"),
108-
Prefix: s("prefix"),
109-
Region: s("region"),
110-
ChainID: s("chainId"),
111-
}
90+
var t SnapshotRestoreTask
91+
switch h := params["targetHeight"].(type) {
92+
case float64:
93+
t.TargetHeight = int64(h)
94+
case int64:
95+
t.TargetHeight = h
96+
}
97+
return t
11298
}
11399

114100
// SnapshotUploadTask archives and streams a local snapshot to S3.

sidecar/client/tasks_test.go

Lines changed: 12 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -14,18 +14,8 @@ func genNonEmptyString() gopter.Gen {
1414
}
1515

1616
func genSnapshotRestoreTask() gopter.Gen {
17-
return gopter.CombineGens(
18-
genNonEmptyString(),
19-
genNonEmptyString(),
20-
genNonEmptyString(),
21-
genNonEmptyString(),
22-
).Map(func(v []interface{}) SnapshotRestoreTask {
23-
return SnapshotRestoreTask{
24-
Bucket: v[0].(string),
25-
Prefix: v[1].(string),
26-
Region: v[2].(string),
27-
ChainID: v[3].(string),
28-
}
17+
return gen.Int64Range(0, 300000000).Map(func(h int64) SnapshotRestoreTask {
18+
return SnapshotRestoreTask{TargetHeight: h}
2919
})
3020
}
3121

@@ -111,11 +101,11 @@ func TestSnapshotRestoreRoundTrip(t *testing.T) {
111101
if req.Type != TaskTypeSnapshotRestore {
112102
return false
113103
}
104+
if task.TargetHeight == 0 {
105+
return req.Params == nil
106+
}
114107
rebuilt := SnapshotRestoreTaskFromParams(*req.Params)
115-
return rebuilt.Bucket == task.Bucket &&
116-
rebuilt.Prefix == task.Prefix &&
117-
rebuilt.Region == task.Region &&
118-
rebuilt.ChainID == task.ChainID
108+
return rebuilt.TargetHeight == task.TargetHeight
119109
},
120110
genSnapshotRestoreTask(),
121111
))
@@ -269,21 +259,19 @@ func TestMarkReadyRoundTrip(t *testing.T) {
269259
}
270260
}
271261

272-
func TestSnapshotRestoreValidationRejectsMissingFields(t *testing.T) {
262+
func TestSnapshotRestoreValidation(t *testing.T) {
263+
// SnapshotRestoreTask has no required fields — TargetHeight=0 means "use latest"
273264
cases := []struct {
274265
name string
275266
task SnapshotRestoreTask
276267
}{
277-
{"missing bucket", SnapshotRestoreTask{Prefix: "p", Region: "r", ChainID: "c"}},
278-
{"missing prefix", SnapshotRestoreTask{Bucket: "b", Region: "r", ChainID: "c"}},
279-
{"missing region", SnapshotRestoreTask{Bucket: "b", Prefix: "p", ChainID: "c"}},
280-
{"missing chainId", SnapshotRestoreTask{Bucket: "b", Prefix: "p", Region: "r"}},
281-
{"all empty", SnapshotRestoreTask{}},
268+
{"zero height (latest)", SnapshotRestoreTask{}},
269+
{"with target height", SnapshotRestoreTask{TargetHeight: 100000000}},
282270
}
283271
for _, tc := range cases {
284272
t.Run(tc.name, func(t *testing.T) {
285-
if err := tc.task.Validate(); err == nil {
286-
t.Error("expected validation error, got nil")
273+
if err := tc.task.Validate(); err != nil {
274+
t.Errorf("unexpected validation error: %v", err)
287275
}
288276
})
289277
}

sidecar/s3/client.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,23 @@ func DefaultUploaderFactory(ctx context.Context, region string) (Uploader, error
4646
return transfermanager.New(s3.NewFromConfig(cfg)), nil
4747
}
4848

49+
// ObjectLister abstracts S3 ListObjectsV2 for snapshot discovery.
50+
type ObjectLister interface {
51+
ListObjectsV2(ctx context.Context, input *s3.ListObjectsV2Input, opts ...func(*s3.Options)) (*s3.ListObjectsV2Output, error)
52+
}
53+
54+
// ObjectListerFactory builds an ObjectLister for a given region.
55+
type ObjectListerFactory func(ctx context.Context, region string) (ObjectLister, error)
56+
57+
// DefaultObjectListerFactory creates a real S3 client for listing objects.
58+
func DefaultObjectListerFactory(ctx context.Context, region string) (ObjectLister, error) {
59+
cfg, err := config.LoadDefaultConfig(ctx, config.WithRegion(region))
60+
if err != nil {
61+
return nil, fmt.Errorf("loading AWS config: %w", err)
62+
}
63+
return s3.NewFromConfig(cfg), nil
64+
}
65+
4966
// WriteAtBuffer is a goroutine-safe in-memory io.WriterAt, used for
5067
// downloading small S3 objects (e.g. latest.txt) via DownloadObject.
5168
type WriteAtBuffer struct {

0 commit comments

Comments
 (0)