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
8 changes: 8 additions & 0 deletions sdk/sei/provider/k8s/render.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,14 @@ func renderNode(spec sei.NodeSpec, namespace, networkNS string) *seiv1alpha1.Sei
if len(spec.Config) > 0 {
node.Spec.Overrides = maps.Clone(spec.Config)
}
if ss := spec.StateSync; ss != nil {
// Bootstrap via CometBFT state sync: the witnesses are the caller's bare
// host:port RpcServers; genesis block-sync leaves Snapshot nil (unchanged).
node.Spec.FullNode.Snapshot = &seiv1alpha1.SnapshotSource{
StateSync: &seiv1alpha1.StateSyncSource{},
RpcServers: ss.RpcServers,
}
}
return node
}

Expand Down
28 changes: 28 additions & 0 deletions sdk/sei/provider/k8s/render_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package k8s

import (
"slices"
"testing"

"github.com/sei-protocol/sei-k8s-controller/sdk/sei"
Expand Down Expand Up @@ -126,6 +127,33 @@ func TestRenderNode_CallerLabelsMergeUnderCanonical(t *testing.T) {
}
}

func TestRenderNode_StateSync(t *testing.T) {
// Two DISTINCT witnesses: the served CRD field is a MinItems=2 listType=set,
// so a duplicated set is not what render carries end to end.
witnesses := []string{"validator-0:26657", "validator-1:26657"}
spec := sei.NodeSpec{
Name: rpc0Name, Network: testNet, Image: testImage,
StateSync: &sei.NodeStateSync{RpcServers: witnesses},
}
node := renderNode(spec, testNS, testNS)

// StateSync maps to spec.fullNode.snapshot with the stateSync variant and the
// caller's bare host:port witnesses.
snap := node.Spec.FullNode.Snapshot
if snap == nil || snap.StateSync == nil {
t.Fatalf("state-sync node must render fullNode.snapshot.stateSync: %+v", node.Spec.FullNode)
}
if !slices.Equal(snap.RpcServers, witnesses) {
t.Errorf("snapshot.rpcServers = %v, want %v", snap.RpcServers, witnesses)
}

// Genesis block-sync (nil StateSync) leaves Snapshot unset — the unchanged path.
genesis := renderNode(sei.NodeSpec{Name: rpc0Name, Network: testNet, Image: testImage}, testNS, testNS)
if genesis.Spec.FullNode.Snapshot != nil {
t.Errorf("genesis node must leave snapshot nil, got %+v", genesis.Spec.FullNode.Snapshot)
}
}

func TestRenderNode_ConfigIntoOverrides(t *testing.T) {
spec := sei.NodeSpec{
Name: rpc0Name, Network: testNet, Image: testImage,
Expand Down
27 changes: 25 additions & 2 deletions sdk/sei/readiness.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,9 @@ type tendermintStatus struct {
}

type syncInfo struct {
LatestBlockHeight string `json:"latest_block_height"`
CatchingUp bool `json:"catching_up"`
LatestBlockHeight string `json:"latest_block_height"`
EarliestBlockHeight string `json:"earliest_block_height"`
CatchingUp bool `json:"catching_up"`
}

func (s *tendermintStatus) sync() syncInfo {
Expand Down Expand Up @@ -92,6 +93,28 @@ func latestHeight(ctx context.Context, hc *http.Client, tmRPC string) (int64, bo
return h, true
}

// EarliestHeight reads tmRPC's earliest retained block height from /status.
// ok=false on an unreachable endpoint or unparseable body. A state-synced node
// reports an earliest > 1 (it started from a snapshot); a genesis-replayed node
// reports 1 — so this is the point read that distinguishes the two. It accepts
// both the enveloped and unwrapped /status shapes the Sei fork emits. Point read
// only; there is no Wait wrapper.
func EarliestHeight(ctx context.Context, hc *http.Client, tmRPC string) (int64, bool) {
body, ok := getJSON(ctx, hc, http.MethodGet, tmRPC+"/status", "")
if !ok {
return 0, false
}
var s tendermintStatus
if json.Unmarshal(body, &s) != nil {
return 0, false
}
h, err := strconv.ParseInt(s.sync().EarliestBlockHeight, 10, 64)
if err != nil {
return 0, false
}
return h, true
}

// WaitHeightAdvances blocks until tmRPC's committed height has risen by at least
// delta from the height observed on the first successful read — proof the chain
// is producing blocks, not merely reachable. Unlike WaitCaughtUp this catches a
Expand Down
14 changes: 14 additions & 0 deletions sdk/sei/sei.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package sei
import (
"context"
"os"
"slices"
"strings"
)

Expand Down Expand Up @@ -218,6 +219,19 @@ func validateNodeSpec(s NodeSpec) error {
return usageErr("NodeSpec.Network is required (the peer-wire target)")
case strings.TrimSpace(s.Image) == "":
return usageErr("NodeSpec.Image is required")
case s.StateSync != nil:
// Count DISTINCT entries, not the raw length: the served CRD field is a
// MinItems=2 listType=set, and the controller normalizes it with the same
// sort+Compact before its canonical-syncer floor — so a duplicated witness
// set collapses below the floor and never produces a state-sync plan.
// Mirror that here rather than green-lighting a set the apiserver/controller
// will reject. Witnesses must be distinct light-client sources.
if distinct := len(slices.Compact(slices.Sorted(slices.Values(s.StateSync.RpcServers)))); distinct < 2 {
// Report the DISTINCT count, not the raw len: the floor is on distinct
// entries, so a two-element set of identical witnesses fails with
// distinct=1 — reporting len=2 would misdescribe the failure.
return usageErr("NodeSpec.StateSync.RpcServers must have >= 2 DISTINCT entries (the state-sync witness floor: served CRD MinItems=2 listType=set + the controller's canonical-syncer floor), got %d distinct", distinct)
}
}
return nil
}
20 changes: 20 additions & 0 deletions sdk/sei/spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -51,4 +51,24 @@ type NodeSpec struct {
Image string // -> spec.image
Config map[string]string // -> spec.overrides (TOML-path keys)
Labels map[string]string // extra labels on the SeiNode object, merged UNDER the canonical sei.io/role + sei.io/seinetwork (which win on key conflict)

// StateSync bootstraps the node through CometBFT state sync instead of
// genesis block-sync; nil => genesis block-sync (unchanged). Set it to bring
// a follower up from a peer-served snapshot rather than replaying from height
// 1. -> spec.fullNode.snapshot{stateSync{}, rpcServers}.
StateSync *NodeStateSync
}

// NodeStateSync configures a SeiNode to bootstrap via CometBFT state sync.
type NodeStateSync struct {
// RpcServers are the light-client witnesses used for trust-point acquisition
// and verification. Entries are BARE host:port (no scheme) and there must be
// >= 2 DISTINCT endpoints: the served CRD field is a MinItems=2 listType=set,
// and the controller sort+dedups the set before its canonical-syncer floor —
// so duplicates collapse below the floor and no state-sync plan is built (the
// gate fails CLOSED). Witnesses must also be distinct light-client sources; an
// aggregate round-robin RPC counted twice is not a sound witness set.
// Network.TendermintRPC() returns a URL, so a caller deriving a witness from
// it must url.Parse(...).Host to strip the scheme. -> spec.fullNode.snapshot.rpcServers.
RpcServers []string
}
13 changes: 13 additions & 0 deletions sdk/sei/spec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,16 @@ package sei

import "testing"

// witnessA and witnessB are the two distinct bare host:port light-client
// witnesses reused across the state-sync validation tests in this package.
// Named consts (mirroring sdk/sei/provider/k8s) keep the literal below the
// goconst threshold. The values are placeholders — the shape (host:port), not
// the address, is what these tests exercise.
const (
witnessA = "a:26657"
witnessB = "b:26657"
)

func TestValidateNetworkSpec(t *testing.T) {
good := NetworkSpec{Name: "net", Image: "img", Validators: 1}
cases := []struct {
Expand Down Expand Up @@ -36,6 +46,9 @@ func TestValidateNodeSpec(t *testing.T) {
{"missing name", func(s *NodeSpec) { s.Name = "" }, true},
{"missing network", func(s *NodeSpec) { s.Network = "" }, true},
{"missing image", func(s *NodeSpec) { s.Image = "" }, true},
{"statesync one witness", func(s *NodeSpec) { s.StateSync = &NodeStateSync{RpcServers: []string{witnessA}} }, true},
{"statesync two identical witnesses", func(s *NodeSpec) { s.StateSync = &NodeStateSync{RpcServers: []string{witnessA, witnessA}} }, true},
{"statesync two distinct witnesses", func(s *NodeSpec) { s.StateSync = &NodeStateSync{RpcServers: []string{witnessA, witnessB}} }, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion sdk/sei/workflow_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ func TestValidateWorkflowSpec(t *testing.T) {
func(s *WorkflowSpec) {
s.StateSync = &StateSyncWorkflow{
ConfigPatch: map[string]map[string]any{"app.toml": {"state-store.evm-ss-split": true}},
RpcServers: []string{"a:26657", "b:26657"},
RpcServers: []string{witnessA, witnessB},
}
},
false,
Expand Down
Loading
Loading