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
40 changes: 40 additions & 0 deletions api/v1alpha1/seinode_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,46 @@ type SeiNodeStatus struct {
// (e.g., manual recreation out-of-band) and triggers replacement.
// +optional
StatefulSet *StatefulSetRef `json:"statefulSet,omitempty"`

// Endpoint is the in-cluster discoverable address(es) for this node, derived
// from its headless Service and mode. It is a DISCOVERABILITY signal, not a
// serve-readiness guarantee: the URL is published once the node is
// PhaseRunning and (for EVM) the mode serves EVM, but the seid listener may
// take additional time to bind — consumers MUST probe before driving load.
// omitempty leaves .status.endpoint absent for nodes that surface nothing.
// +optional
Endpoint *NodeEndpointStatus `json:"endpoint,omitempty"`
}

// NodeEndpointStatus carries the in-cluster URLs this SeiNode serves, derived
// from its headless Service and operating mode. EVM URLs are populated only
// when the node's mode serves EVM HTTP/WS (fullNode, archive); validator and
// replayer modes leave them empty (validator mode disables EVM). All URLs
// resolve to the node's headless Service at <name>.<namespace>.svc. Field names
// match SeiNetwork's NodeEndpoint leaf (evmJsonRpc, evmWs) so consumers parse
// one shape across both CRDs.
type NodeEndpointStatus struct {
// EvmJsonRpc is the EVM JSON-RPC HTTP URL (http://). Empty unless the
// node's mode serves EVM (fullNode, archive).
// +optional
EvmJsonRpc string `json:"evmJsonRpc,omitempty"`

// EvmWs is the EVM WebSocket URL (ws://). Empty unless the node's mode
// serves EVM (fullNode, archive).
// +optional
EvmWs string `json:"evmWs,omitempty"`

// TendermintRpc is the Tendermint / CometBFT RPC URL (http://). Populated
// only for fullNode/archive (gated by servesEVM); not surfaced for
// validator/replayer — validators do bind RPC on 0.0.0.0 but we don't
// advertise it.
// +optional
TendermintRpc string `json:"tendermintRpc,omitempty"`

// TendermintRest is the Cosmos REST (LCD) URL (http://). Served only by
// fullNode/archive; validators disable the REST API.
// +optional
TendermintRest string `json:"tendermintRest,omitempty"`
}

// StatefulSetRef identifies a StatefulSet owned and managed by a
Expand Down
20 changes: 20 additions & 0 deletions api/v1alpha1/zz_generated.deepcopy.go

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

32 changes: 32 additions & 0 deletions config/crd/sei.io_seinodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,38 @@ spec:
as no-drift so a controller upgrade doesn't fleet-roll every node
on first reconcile.
type: string
endpoint:
description: |-
Endpoint is the in-cluster discoverable address(es) for this node, derived
from its headless Service and mode. It is a DISCOVERABILITY signal, not a
serve-readiness guarantee: the URL is published once the node is
PhaseRunning and (for EVM) the mode serves EVM, but the seid listener may
take additional time to bind — consumers MUST probe before driving load.
omitempty leaves .status.endpoint absent for nodes that surface nothing.
properties:
evmJsonRpc:
description: |-
EvmJsonRpc is the EVM JSON-RPC HTTP URL (http://). Empty unless the
node's mode serves EVM (fullNode, archive).
type: string
evmWs:
description: |-
EvmWs is the EVM WebSocket URL (ws://). Empty unless the node's mode
serves EVM (fullNode, archive).
type: string
tendermintRest:
description: |-
TendermintRest is the Cosmos REST (LCD) URL (http://). Served only by
fullNode/archive; validators disable the REST API.
type: string
tendermintRpc:
description: |-
TendermintRpc is the Tendermint / CometBFT RPC URL (http://). Populated
only for fullNode/archive (gated by servesEVM); not surfaced for
validator/replayer — validators do bind RPC on 0.0.0.0 but we don't
advertise it.
type: string
type: object
phase:
description: Phase is the high-level lifecycle state.
enum:
Expand Down
7 changes: 7 additions & 0 deletions internal/controller/node/controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ func (r *SeiNodeReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ct
result, execErr = r.PlanExecutor.ExecutePlan(ctx, node, node.Status.Plan)
}

// Set only when Running and never cleared on a transient non-Running: the
// URLs are identity-derived, so a Running->update->Running cycle keeps them
// stable and clearing would flap .status.endpoint for consumers.
if node.Status.Phase == seiv1alpha1.PhaseRunning {
node.Status.Endpoint = composeNodeEndpoints(node)
}

if !apiequality.Semantic.DeepEqual(before.Status, node.Status) {
if err := r.Status().Patch(ctx, node, statusBase); err != nil {
if execErr != nil {
Expand Down
43 changes: 43 additions & 0 deletions internal/controller/node/endpoints.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package node

import (
"fmt"

seiconfig "github.com/sei-protocol/sei-config"

seiv1alpha1 "github.com/sei-protocol/sei-k8s-controller/api/v1alpha1"
)

// servesEVM reports whether this node's mode serves EVM HTTP/WS. Only fullNode
// and archive do; validator mode disables EVM and replayer is an ephemeral,
// RPC-less restore workload. Gates on the spec sub-spec, not
// noderesource.NodeMode, which collapses replayer -> ModeFull and would wrongly
// surface endpoints for an ephemeral replayer.
func servesEVM(node *seiv1alpha1.SeiNode) bool {
return node.Spec.FullNode != nil || node.Spec.Archive != nil
}

// composeNodeEndpoints derives the in-cluster URL bundle for this node from its
// headless Service DNS (<name>.<namespace>.svc) and the seiconfig port set.
// Returns nil for modes that serve no EVM, so omitempty leaves .status.endpoint
// absent.
func composeNodeEndpoints(node *seiv1alpha1.SeiNode) *seiv1alpha1.NodeEndpointStatus {
if !servesEVM(node) {
return nil
}
ns, name := node.Namespace, node.Name
return &seiv1alpha1.NodeEndpointStatus{
EvmJsonRpc: httpURL(name, ns, seiconfig.PortEVMHTTP),
EvmWs: wsURL(name, ns, seiconfig.PortEVMWS),
TendermintRpc: httpURL(name, ns, seiconfig.PortRPC),
TendermintRest: httpURL(name, ns, seiconfig.PortREST),
}
}

func httpURL(service, namespace string, port int32) string {
return fmt.Sprintf("http://%s.%s.svc:%d", service, namespace, port)
}

func wsURL(service, namespace string, port int32) string {
return fmt.Sprintf("ws://%s.%s.svc:%d", service, namespace, port)
}
81 changes: 81 additions & 0 deletions internal/controller/node/endpoints_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package node

import (
"testing"

. "github.com/onsi/gomega"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"

seiv1alpha1 "github.com/sei-protocol/sei-k8s-controller/api/v1alpha1"
)

func endpointNode(name, namespace string, spec seiv1alpha1.SeiNodeSpec) *seiv1alpha1.SeiNode { //nolint:unparam // test helper designed for reuse
return &seiv1alpha1.SeiNode{
ObjectMeta: metav1.ObjectMeta{Name: name, Namespace: namespace},
Spec: spec,
}
}

func TestServesEVM_TruthTable(t *testing.T) {
g := NewWithT(t)
cases := []struct {
name string
spec seiv1alpha1.SeiNodeSpec
want bool
}{
{"fullNode", seiv1alpha1.SeiNodeSpec{FullNode: &seiv1alpha1.FullNodeSpec{}}, true},
{"archive", seiv1alpha1.SeiNodeSpec{Archive: &seiv1alpha1.ArchiveSpec{}}, true},
{"validator", seiv1alpha1.SeiNodeSpec{Validator: &seiv1alpha1.ValidatorSpec{}}, false},
{"replayer", seiv1alpha1.SeiNodeSpec{Replayer: &seiv1alpha1.ReplayerSpec{}}, false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
g.Expect(servesEVM(endpointNode("n", "sei", tc.spec))).To(Equal(tc.want))
})
}
}

func TestComposeNodeEndpoints_FullNode(t *testing.T) {
g := NewWithT(t)
node := endpointNode("chaos-rpc", "sei", seiv1alpha1.SeiNodeSpec{FullNode: &seiv1alpha1.FullNodeSpec{}})

got := composeNodeEndpoints(node)

g.Expect(got).To(Equal(&seiv1alpha1.NodeEndpointStatus{
EvmJsonRpc: "http://chaos-rpc.sei.svc:8545",
EvmWs: "ws://chaos-rpc.sei.svc:8546",
TendermintRpc: "http://chaos-rpc.sei.svc:26657",
TendermintRest: "http://chaos-rpc.sei.svc:1317",
}))
}

func TestComposeNodeEndpoints_Archive(t *testing.T) {
g := NewWithT(t)
node := endpointNode("chaos-archive", "sei", seiv1alpha1.SeiNodeSpec{Archive: &seiv1alpha1.ArchiveSpec{}})

got := composeNodeEndpoints(node)

g.Expect(got).To(Equal(&seiv1alpha1.NodeEndpointStatus{
EvmJsonRpc: "http://chaos-archive.sei.svc:8545",
EvmWs: "ws://chaos-archive.sei.svc:8546",
TendermintRpc: "http://chaos-archive.sei.svc:26657",
TendermintRest: "http://chaos-archive.sei.svc:1317",
}))
}

func TestComposeNodeEndpoints_ValidatorNil(t *testing.T) {
g := NewWithT(t)
node := endpointNode("genesis-val-0", "sei", seiv1alpha1.SeiNodeSpec{Validator: &seiv1alpha1.ValidatorSpec{}})

g.Expect(composeNodeEndpoints(node)).To(BeNil())
}

// Replayer must return nil. noderesource.NodeMode collapses replayer -> ModeFull,
// so a mode-string gate would mis-classify it as EVM-serving; servesEVM gates on
// the spec sub-spec to avoid that. This is the ModeFull-collapse regression guard.
func TestComposeNodeEndpoints_ReplayerNil(t *testing.T) {
g := NewWithT(t)
node := endpointNode("replay-0", "sei", seiv1alpha1.SeiNodeSpec{Replayer: &seiv1alpha1.ReplayerSpec{}})

g.Expect(composeNodeEndpoints(node)).To(BeNil())
}
92 changes: 92 additions & 0 deletions internal/controller/node/reconciler_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,98 @@ func TestNodeReconcile_SnapshotNode_StatefulSetHasInitContainers(t *testing.T) {
g.Expect(sts.Spec.Template.Spec.InitContainers[2].Name).To(Equal("kube-rbac-proxy"))
}

// runningFullNode returns a fullNode pre-seeded to Running with its STS already
// present, mirroring TestNodeReconcile_RunningPhase_UpdatesStatefulSetImage. Used
// to exercise the endpoint-status derivation, which only fires once Running.
func runningFullNode(t *testing.T, name, namespace string) (*seiv1alpha1.SeiNode, *appsv1.StatefulSet) {
t.Helper()
node := newSnapshotNode(name, namespace) // fullNode shape
node.Spec.FullNode.Snapshot = nil // plain follower, no restore
node.Finalizers = []string{nodeFinalizerName}
node.Status.Phase = seiv1alpha1.PhaseRunning
node.Status.CurrentImage = node.Spec.Image

sts, err := noderesource.GenerateStatefulSet(node, platformtest.Config())
if err != nil {
t.Fatalf("generating statefulset: %v", err)
}
sts.SetGroupVersionKind(appsv1.SchemeGroupVersion.WithKind("StatefulSet"))
return node, sts
}

func TestNodeReconcile_RunningFullNode_SetsEndpoint(t *testing.T) {
g := NewWithT(t)
ctx := context.Background()

node, sts := runningFullNode(t, "chaos-rpc", "sei")
svc := noderesource.GenerateHeadlessService(node)
svc.SetGroupVersionKind(corev1.SchemeGroupVersion.WithKind("Service"))
r, c := newNodeReconciler(t, node, sts, svc)

_, err := r.Reconcile(ctx, nodeReqFor("chaos-rpc", "sei"))
g.Expect(err).NotTo(HaveOccurred())

got := getSeiNode(t, ctx, c, "chaos-rpc", "sei")
g.Expect(got.Status.Endpoint).NotTo(BeNil())
g.Expect(got.Status.Endpoint.EvmJsonRpc).To(Equal("http://chaos-rpc.sei.svc:8545"))
g.Expect(got.Status.Endpoint.EvmWs).To(Equal("ws://chaos-rpc.sei.svc:8546"))
g.Expect(got.Status.Endpoint.TendermintRpc).To(Equal("http://chaos-rpc.sei.svc:26657"))
g.Expect(got.Status.Endpoint.TendermintRest).To(Equal("http://chaos-rpc.sei.svc:1317"))

// The headless Service the URL resolves to exists with a :8545 port.
gotSvc := &corev1.Service{}
g.Expect(c.Get(ctx, types.NamespacedName{Name: "chaos-rpc", Namespace: "sei"}, gotSvc)).To(Succeed())
g.Expect(gotSvc.Spec.ClusterIP).To(Equal(corev1.ClusterIPNone))
var has8545 bool
for _, p := range gotSvc.Spec.Ports {
if p.Port == 8545 {
has8545 = true
}
}
g.Expect(has8545).To(BeTrue(), "headless Service should expose :8545")
}

func TestNodeReconcile_RunningValidator_NoEndpoint(t *testing.T) {
g := NewWithT(t)
ctx := context.Background()

node := newGenesisNode("genesis-val-0", "sei")
node.Finalizers = []string{nodeFinalizerName}
node.Status.Phase = seiv1alpha1.PhaseRunning
node.Status.CurrentImage = node.Spec.Image

sts, err := noderesource.GenerateStatefulSet(node, platformtest.Config())
g.Expect(err).NotTo(HaveOccurred())
sts.SetGroupVersionKind(appsv1.SchemeGroupVersion.WithKind("StatefulSet"))

r, c := newNodeReconciler(t, node, sts)

_, err = r.Reconcile(ctx, nodeReqFor("genesis-val-0", "sei"))
g.Expect(err).NotTo(HaveOccurred())

got := getSeiNode(t, ctx, c, "genesis-val-0", "sei")
g.Expect(got.Status.Endpoint).To(BeNil())
}

func TestNodeReconcile_Endpoint_StableAcrossReconciles(t *testing.T) {
g := NewWithT(t)
ctx := context.Background()

node, sts := runningFullNode(t, "chaos-rpc", "sei")
r, c := newNodeReconciler(t, node, sts)

_, err := r.Reconcile(ctx, nodeReqFor("chaos-rpc", "sei"))
g.Expect(err).NotTo(HaveOccurred())
first := getSeiNode(t, ctx, c, "chaos-rpc", "sei").Status.Endpoint
g.Expect(first).NotTo(BeNil())

// Second reconcile: identity-derived URLs are unchanged (no churn).
_, err = r.Reconcile(ctx, nodeReqFor("chaos-rpc", "sei"))
g.Expect(err).NotTo(HaveOccurred())
second := getSeiNode(t, ctx, c, "chaos-rpc", "sei").Status.Endpoint
g.Expect(second).To(Equal(first))
}

func TestNodeReconcile_RunningPhase_UpdatesStatefulSetImage(t *testing.T) {
g := NewWithT(t)
ctx := context.Background()
Expand Down
32 changes: 32 additions & 0 deletions manifests/sei.io_seinodes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -821,6 +821,38 @@ spec:
as no-drift so a controller upgrade doesn't fleet-roll every node
on first reconcile.
type: string
endpoint:
description: |-
Endpoint is the in-cluster discoverable address(es) for this node, derived
from its headless Service and mode. It is a DISCOVERABILITY signal, not a
serve-readiness guarantee: the URL is published once the node is
PhaseRunning and (for EVM) the mode serves EVM, but the seid listener may
take additional time to bind — consumers MUST probe before driving load.
omitempty leaves .status.endpoint absent for nodes that surface nothing.
properties:
evmJsonRpc:
description: |-
EvmJsonRpc is the EVM JSON-RPC HTTP URL (http://). Empty unless the
node's mode serves EVM (fullNode, archive).
type: string
evmWs:
description: |-
EvmWs is the EVM WebSocket URL (ws://). Empty unless the node's mode
serves EVM (fullNode, archive).
type: string
tendermintRest:
description: |-
TendermintRest is the Cosmos REST (LCD) URL (http://). Served only by
fullNode/archive; validators disable the REST API.
type: string
tendermintRpc:
description: |-
TendermintRpc is the Tendermint / CometBFT RPC URL (http://). Populated
only for fullNode/archive (gated by servesEVM); not surfaced for
validator/replayer — validators do bind RPC on 0.0.0.0 but we don't
advertise it.
type: string
type: object
phase:
description: Phase is the high-level lifecycle state.
enum:
Expand Down
Loading