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
57 changes: 37 additions & 20 deletions api/v1alpha1/seinodedeployment_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -235,8 +235,8 @@ type SeiNodeDeploymentStatus struct {
PerPodServices []PerPodServiceStatus `json:"perPodServices,omitempty"`

// Endpoints exposes composed in-cluster URLs derived from
// .status.internalService and .status.perPodServices. See the Endpoints
// type for the ordering contract.
// .status.internalService and .status.perPodServices. When
// .status.phase == Ready, .nodes is non-empty.
// +optional
Endpoints *Endpoints `json:"endpoints,omitempty"`

Expand All @@ -246,33 +246,50 @@ type SeiNodeDeploymentStatus struct {
Conditions []metav1.Condition `json:"conditions,omitempty"`
}

// Endpoints lists composed in-cluster URLs per protocol.
// Endpoints lists composed in-cluster URLs for consuming this deployment.
// Aggregate URLs sit at top-level scalars; per-pod URLs live in Nodes,
// keyed by SeiNode name. When .status.phase == Ready, Nodes is non-empty
// and each entry has at least Name plus its protocol URLs populated.
//
// Ordering: aggregate ClusterIP URL at index 0, per-pod URLs at indices
// 1..N in .status.perPodServices order. EvmWs has no aggregate entry —
// round-robin ClusterIP does not preserve WebSocket subscription state,
// so its entries are per-pod-only at indices 0..N-1.
// Stateless protocols (Tendermint RPC, Tendermint REST) are surfaced only
// at the aggregate level — kube-proxy round-robins safely. Stateful
// protocols (EVM JSON-RPC: filters, mempool, finalized-tag; EVM WebSocket:
// subscriptions) are surfaced only per-pod, because they do not
// load-balance correctly behind a kube-proxy L4 LB. Consumers that need
// state-consistent EVM sequences pin to a single Nodes[N].
type Endpoints struct {
// TendermintRpc lists Tendermint / CometBFT RPC URLs (http://). Aggregate
// only until per-pod Services expose port 26657.
// TendermintRpc is the aggregate Tendermint / CometBFT RPC URL (http://).
// +optional
TendermintRpc []string `json:"tendermintRpc,omitempty"`
TendermintRpc string `json:"tendermintRpc,omitempty"`

// TendermintRest lists Cosmos REST (LCD) URLs (http://). Aggregate only
// until per-pod Services expose port 1317.
// TendermintRest is the aggregate Cosmos REST (LCD) URL (http://).
// +optional
TendermintRest []string `json:"tendermintRest,omitempty"`
TendermintRest string `json:"tendermintRest,omitempty"`

// EvmJsonRpc lists EVM JSON-RPC HTTP URLs (http://). Aggregate at index 0,
// per-pod URLs at indices 1..N.
// Nodes lists per-pod URL bundles, keyed by SeiNode name. The list
// mirrors .status.perPodServices and exposes the protocols that
// require pod affinity (EVM JSON-RPC, EVM WebSocket).
// +listType=map
// +listMapKey=name
// +optional
Nodes []NodeEndpoint `json:"nodes,omitempty"`
}

// NodeEndpoint is the per-pod URL bundle for a single SeiNode replica.
// Name matches the SeiNode resource name and the corresponding entry in
// .status.perPodServices.
type NodeEndpoint struct {
// Name is the SeiNode resource name (also the headless Service name).
// +kubebuilder:validation:MinLength=1
Name string `json:"name"`

// EvmJsonRpc is the per-pod EVM JSON-RPC HTTP URL (http://).
// +optional
EvmJsonRpc []string `json:"evmJsonRpc,omitempty"`
EvmJsonRpc string `json:"evmJsonRpc,omitempty"`

// EvmWs lists EVM WebSocket URLs (ws://). Per-pod only — no aggregate
// entry, since round-robin ClusterIP breaks WebSocket subscription
// affinity.
// EvmWs is the per-pod EVM WebSocket URL (ws://).
// +optional
EvmWs []string `json:"evmWs,omitempty"`
EvmWs string `json:"evmWs,omitempty"`
}

// InternalServiceStatus reports the resolved in-cluster ClusterIP Service
Expand Down
36 changes: 18 additions & 18 deletions api/v1alpha1/zz_generated.deepcopy.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

61 changes: 35 additions & 26 deletions config/crd/sei.io_seinodedeployments.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -1034,38 +1034,47 @@ spec:
endpoints:
description: |-
Endpoints exposes composed in-cluster URLs derived from
.status.internalService and .status.perPodServices. See the Endpoints
type for the ordering contract.
.status.internalService and .status.perPodServices. When
.status.phase == Ready, .nodes is non-empty.
properties:
evmJsonRpc:
nodes:
description: |-
EvmJsonRpc lists EVM JSON-RPC HTTP URLs (http://). Aggregate at index 0,
per-pod URLs at indices 1..N.
Nodes lists per-pod URL bundles, keyed by SeiNode name. The list
mirrors .status.perPodServices and exposes the protocols that
require pod affinity (EVM JSON-RPC, EVM WebSocket).
items:
type: string
type: array
evmWs:
description: |-
EvmWs lists EVM WebSocket URLs (ws://). Per-pod only — no aggregate
entry, since round-robin ClusterIP breaks WebSocket subscription
affinity.
items:
type: string
description: |-
NodeEndpoint is the per-pod URL bundle for a single SeiNode replica.
Name matches the SeiNode resource name and the corresponding entry in
.status.perPodServices.
properties:
evmJsonRpc:
description: EvmJsonRpc is the per-pod EVM JSON-RPC HTTP
URL (http://).
type: string
evmWs:
description: EvmWs is the per-pod EVM WebSocket URL (ws://).
type: string
name:
description: Name is the SeiNode resource name (also the
headless Service name).
minLength: 1
type: string
required:
- name
type: object
type: array
x-kubernetes-list-map-keys:
- name
x-kubernetes-list-type: map
tendermintRest:
description: |-
TendermintRest lists Cosmos REST (LCD) URLs (http://). Aggregate only
until per-pod Services expose port 1317.
items:
type: string
type: array
description: TendermintRest is the aggregate Cosmos REST (LCD)
URL (http://).
type: string
tendermintRpc:
description: |-
TendermintRpc lists Tendermint / CometBFT RPC URLs (http://). Aggregate
only until per-pod Services expose port 26657.
items:
type: string
type: array
description: TendermintRpc is the aggregate Tendermint / CometBFT
RPC URL (http://).
type: string
type: object
genesisHash:
description: GenesisHash is the SHA-256 hex digest of the assembled
Expand Down
26 changes: 17 additions & 9 deletions internal/controller/nodedeployment/endpoints.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,15 @@ import (
)

// composeEndpoints builds Endpoints from the resolved status fields.
// Returns nil when neither has been observed, so omitempty leaves
// .status.endpoints absent. See the Endpoints type for the ordering contract.
// Returns nil when neither InternalService nor PerPodServices has been
// observed, so omitempty leaves .status.endpoints absent.
//
// Aggregate scalars (TendermintRpc, TendermintRest) come from
// InternalService; per-pod NodeEndpoint entries come from PerPodServices
// and preserve its order. EVM JSON-RPC and EVM WebSocket are surfaced
// per-pod only — the aggregate ClusterIP does not load-balance correctly
// for stateful EVM sequences (filters, mempool, finalized-tag,
// subscriptions). Consumers that need pod affinity pin to Nodes[N].
func composeEndpoints(group *seiv1alpha1.SeiNodeDeployment) *seiv1alpha1.Endpoints {
internal := group.Status.InternalService
perPod := group.Status.PerPodServices
Expand All @@ -19,15 +26,16 @@ func composeEndpoints(group *seiv1alpha1.SeiNodeDeployment) *seiv1alpha1.Endpoin
out := &seiv1alpha1.Endpoints{}

if internal != nil {
out.TendermintRpc = []string{httpURL(internal.Name, internal.Namespace, internal.Ports.Rpc)}
out.TendermintRest = []string{httpURL(internal.Name, internal.Namespace, internal.Ports.Rest)}
out.EvmJsonRpc = []string{httpURL(internal.Name, internal.Namespace, internal.Ports.EvmHttp)}
out.TendermintRpc = httpURL(internal.Name, internal.Namespace, internal.Ports.Rpc)
out.TendermintRest = httpURL(internal.Name, internal.Namespace, internal.Ports.Rest)
}

for i := range perPod {
p := &perPod[i]
out.EvmJsonRpc = append(out.EvmJsonRpc, httpURL(p.Name, p.Namespace, p.Ports.EvmHttp))
out.EvmWs = append(out.EvmWs, wsURL(p.Name, p.Namespace, p.Ports.EvmWs))
for _, p := range perPod {
out.Nodes = append(out.Nodes, seiv1alpha1.NodeEndpoint{
Name: p.Name,
EvmJsonRpc: httpURL(p.Name, p.Namespace, p.Ports.EvmHttp),
EvmWs: wsURL(p.Name, p.Namespace, p.Ports.EvmWs),
})
}

return out
Expand Down
93 changes: 44 additions & 49 deletions internal/controller/nodedeployment/endpoints_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,28 +43,20 @@ func TestComposeEndpoints_NilWhenStatusEmpty(t *testing.T) {
g.Expect(got).To(BeNil())
}

func TestComposeEndpoints_EvmJsonRpcAggregateThenPerPod(t *testing.T) {
func TestComposeEndpoints_TendermintScalarsFromInternalService(t *testing.T) {
g := NewWithT(t)
group := &seiv1alpha1.SeiNodeDeployment{}
group.Status.InternalService = internalServiceStatus()
group.Status.PerPodServices = []seiv1alpha1.PerPodServiceStatus{
perPodServiceStatus("pacific-1-wave-0"),
perPodServiceStatus("pacific-1-wave-1"),
perPodServiceStatus("pacific-1-wave-2"),
}

got := composeEndpoints(group)

g.Expect(got).NotTo(BeNil())
// 1 aggregate + N per-pod
g.Expect(got.EvmJsonRpc).To(HaveLen(4))
g.Expect(got.EvmJsonRpc[0]).To(Equal("http://pacific-1-wave-internal.pacific-1.svc:8545"))
g.Expect(got.EvmJsonRpc[1]).To(Equal("http://pacific-1-wave-0.pacific-1.svc:8545"))
g.Expect(got.EvmJsonRpc[2]).To(Equal("http://pacific-1-wave-1.pacific-1.svc:8545"))
g.Expect(got.EvmJsonRpc[3]).To(Equal("http://pacific-1-wave-2.pacific-1.svc:8545"))
g.Expect(got.TendermintRpc).To(Equal("http://pacific-1-wave-internal.pacific-1.svc:26657"))
g.Expect(got.TendermintRest).To(Equal("http://pacific-1-wave-internal.pacific-1.svc:1317"))
g.Expect(got.Nodes).To(BeEmpty())
}

func TestComposeEndpoints_EvmWsPerPodOnlyInOrder(t *testing.T) {
func TestComposeEndpoints_NodesPerPodInOrder(t *testing.T) {
g := NewWithT(t)
group := &seiv1alpha1.SeiNodeDeployment{}
group.Status.InternalService = internalServiceStatus()
Expand All @@ -77,67 +69,70 @@ func TestComposeEndpoints_EvmWsPerPodOnlyInOrder(t *testing.T) {
got := composeEndpoints(group)

g.Expect(got).NotTo(BeNil())
// Per-pod-only (no aggregate): exactly N entries.
g.Expect(got.EvmWs).To(HaveLen(3))
g.Expect(got.EvmWs[0]).To(Equal("ws://pacific-1-wave-0.pacific-1.svc:8546"))
g.Expect(got.EvmWs[1]).To(Equal("ws://pacific-1-wave-1.pacific-1.svc:8546"))
g.Expect(got.EvmWs[2]).To(Equal("ws://pacific-1-wave-2.pacific-1.svc:8546"))
g.Expect(got.Nodes).To(Equal([]seiv1alpha1.NodeEndpoint{
{
Name: "pacific-1-wave-0",
EvmJsonRpc: "http://pacific-1-wave-0.pacific-1.svc:8545",
EvmWs: "ws://pacific-1-wave-0.pacific-1.svc:8546",
},
{
Name: "pacific-1-wave-1",
EvmJsonRpc: "http://pacific-1-wave-1.pacific-1.svc:8545",
EvmWs: "ws://pacific-1-wave-1.pacific-1.svc:8546",
},
{
Name: "pacific-1-wave-2",
EvmJsonRpc: "http://pacific-1-wave-2.pacific-1.svc:8545",
EvmWs: "ws://pacific-1-wave-2.pacific-1.svc:8546",
},
}))
}

func TestComposeEndpoints_TendermintAggregateOnlyToday(t *testing.T) {
// PerPodServicePorts has no Rpc/Rest fields, so per-pod entries cannot be appended.
func TestComposeEndpoints_NodesOrderMirrorsStatus(t *testing.T) {
// Inputs in non-sorted order; composeEndpoints must preserve it (no re-sort).
g := NewWithT(t)
group := &seiv1alpha1.SeiNodeDeployment{}
group.Status.InternalService = internalServiceStatus()
group.Status.PerPodServices = []seiv1alpha1.PerPodServiceStatus{
perPodServiceStatus("pacific-1-wave-2"),
perPodServiceStatus("pacific-1-wave-0"),
perPodServiceStatus("pacific-1-wave-1"),
perPodServiceStatus("pacific-1-wave-5"),
}

got := composeEndpoints(group)

g.Expect(got).NotTo(BeNil())
g.Expect(got.TendermintRpc).To(ConsistOf("http://pacific-1-wave-internal.pacific-1.svc:26657"))
g.Expect(got.TendermintRest).To(ConsistOf("http://pacific-1-wave-internal.pacific-1.svc:1317"))
names := []string{got.Nodes[0].Name, got.Nodes[1].Name, got.Nodes[2].Name}
g.Expect(names).To(Equal([]string{"pacific-1-wave-2", "pacific-1-wave-0", "pacific-1-wave-5"}))
}

func TestComposeEndpoints_PerPodOrderMirrorsStatus(t *testing.T) {
// Inputs in non-sorted order; composeEndpoints must preserve it (no re-sort).
func TestComposeEndpoints_AggregateOnlyBeforePerPodObserved(t *testing.T) {
// Transient early-reconcile state: internal Service applied before any per-pod Service.
g := NewWithT(t)
group := &seiv1alpha1.SeiNodeDeployment{}
group.Status.InternalService = internalServiceStatus()
group.Status.PerPodServices = []seiv1alpha1.PerPodServiceStatus{
perPodServiceStatus("pacific-1-wave-2"),
perPodServiceStatus("pacific-1-wave-0"),
perPodServiceStatus("pacific-1-wave-5"),
}

got := composeEndpoints(group)

g.Expect(got.EvmJsonRpc).To(Equal([]string{
"http://pacific-1-wave-internal.pacific-1.svc:8545",
"http://pacific-1-wave-2.pacific-1.svc:8545",
"http://pacific-1-wave-0.pacific-1.svc:8545",
"http://pacific-1-wave-5.pacific-1.svc:8545",
}))
g.Expect(got.EvmWs).To(Equal([]string{
"ws://pacific-1-wave-2.pacific-1.svc:8546",
"ws://pacific-1-wave-0.pacific-1.svc:8546",
"ws://pacific-1-wave-5.pacific-1.svc:8546",
}))
g.Expect(got).NotTo(BeNil())
g.Expect(got.TendermintRpc).NotTo(BeEmpty())
g.Expect(got.TendermintRest).NotTo(BeEmpty())
g.Expect(got.Nodes).To(BeEmpty())
}

func TestComposeEndpoints_AggregateOnlyBeforePerPodObserved(t *testing.T) {
// Transient early-reconcile state: internal Service applied before any per-pod Service.
func TestComposeEndpoints_PerPodOnlyBeforeInternalObserved(t *testing.T) {
// Inverse transient: per-pod Services applied before internal Service.
g := NewWithT(t)
group := &seiv1alpha1.SeiNodeDeployment{}
group.Status.InternalService = internalServiceStatus()
group.Status.PerPodServices = []seiv1alpha1.PerPodServiceStatus{
perPodServiceStatus("pacific-1-wave-0"),
}

got := composeEndpoints(group)

g.Expect(got).NotTo(BeNil())
g.Expect(got.EvmJsonRpc).To(ConsistOf("http://pacific-1-wave-internal.pacific-1.svc:8545"))
g.Expect(got.EvmWs).To(BeEmpty())
g.Expect(got.TendermintRpc).To(HaveLen(1))
g.Expect(got.TendermintRest).To(HaveLen(1))
g.Expect(got.TendermintRpc).To(BeEmpty())
g.Expect(got.TendermintRest).To(BeEmpty())
g.Expect(got.Nodes).To(HaveLen(1))
g.Expect(got.Nodes[0].EvmJsonRpc).To(Equal("http://pacific-1-wave-0.pacific-1.svc:8545"))
g.Expect(got.Nodes[0].EvmWs).To(Equal("ws://pacific-1-wave-0.pacific-1.svc:8546"))
}
Loading
Loading