diff --git a/api/v1alpha1/seinodedeployment_types.go b/api/v1alpha1/seinodedeployment_types.go index d91116d3..4ca9baa8 100644 --- a/api/v1alpha1/seinodedeployment_types.go +++ b/api/v1alpha1/seinodedeployment_types.go @@ -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"` @@ -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 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index 12f65c8b..efa79705 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -91,24 +91,9 @@ func (in *EC2TagsPeerSource) DeepCopy() *EC2TagsPeerSource { // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *Endpoints) DeepCopyInto(out *Endpoints) { *out = *in - if in.TendermintRpc != nil { - in, out := &in.TendermintRpc, &out.TendermintRpc - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.TendermintRest != nil { - in, out := &in.TendermintRest, &out.TendermintRest - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.EvmJsonRpc != nil { - in, out := &in.EvmJsonRpc, &out.EvmJsonRpc - *out = make([]string, len(*in)) - copy(*out, *in) - } - if in.EvmWs != nil { - in, out := &in.EvmWs, &out.EvmWs - *out = make([]string, len(*in)) + if in.Nodes != nil { + in, out := &in.Nodes, &out.Nodes + *out = make([]NodeEndpoint, len(*in)) copy(*out, *in) } } @@ -388,6 +373,21 @@ func (in *NetworkingStatus) DeepCopy() *NetworkingStatus { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeEndpoint) DeepCopyInto(out *NodeEndpoint) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeEndpoint. +func (in *NodeEndpoint) DeepCopy() *NodeEndpoint { + if in == nil { + return nil + } + out := new(NodeEndpoint) + in.DeepCopyInto(out) + return out +} + // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. func (in *NodeKeySource) DeepCopyInto(out *NodeKeySource) { *out = *in diff --git a/config/crd/sei.io_seinodedeployments.yaml b/config/crd/sei.io_seinodedeployments.yaml index 259afbe3..329ad8db 100644 --- a/config/crd/sei.io_seinodedeployments.yaml +++ b/config/crd/sei.io_seinodedeployments.yaml @@ -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 diff --git a/internal/controller/nodedeployment/endpoints.go b/internal/controller/nodedeployment/endpoints.go index 922d3091..34340fde 100644 --- a/internal/controller/nodedeployment/endpoints.go +++ b/internal/controller/nodedeployment/endpoints.go @@ -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 @@ -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 diff --git a/internal/controller/nodedeployment/endpoints_test.go b/internal/controller/nodedeployment/endpoints_test.go index 63419b46..2894c04d 100644 --- a/internal/controller/nodedeployment/endpoints_test.go +++ b/internal/controller/nodedeployment/endpoints_test.go @@ -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() @@ -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")) } diff --git a/manifests/sei.io_seinodedeployments.yaml b/manifests/sei.io_seinodedeployments.yaml index 259afbe3..329ad8db 100644 --- a/manifests/sei.io_seinodedeployments.yaml +++ b/manifests/sei.io_seinodedeployments.yaml @@ -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