From ca7ccd394dacbe63f8f028528cc4df84b6bf8077 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 18 Jun 2026 11:55:55 -0700 Subject: [PATCH 1/2] feat(seinode): publish per-node endpoint URLs in status MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add SeiNode.status.endpoint (NodeEndpointStatus) so a follower node's in-cluster EVM/Tendermint URLs are discoverable by external orchestration. Gated on EVM-serving mode (fullNode/archive); validator/replayer surface nothing. Pure status derivation from the existing headless Service — no new Service, no RBAC change. The controller publishes the discoverable address only; readiness probing and load-balancing decisions belong to the consumer, not the controller. Unblocks the seictl/platform migration off SeiNodeDeployment: the follower RPC fleet (not the genesis validators, which don't serve EVM) becomes the load target. Co-Authored-By: Claude Opus 4.8 --- api/v1alpha1/seinode_types.go | 40 +++++++++ api/v1alpha1/zz_generated.deepcopy.go | 20 +++++ config/crd/sei.io_seinodes.yaml | 33 ++++++++ internal/controller/node/controller.go | 7 ++ internal/controller/node/endpoints.go | 43 ++++++++++ internal/controller/node/endpoints_test.go | 81 ++++++++++++++++++ internal/controller/node/reconciler_test.go | 92 +++++++++++++++++++++ manifests/sei.io_seinodes.yaml | 33 ++++++++ 8 files changed, 349 insertions(+) create mode 100644 internal/controller/node/endpoints.go create mode 100644 internal/controller/node/endpoints_test.go diff --git a/api/v1alpha1/seinode_types.go b/api/v1alpha1/seinode_types.go index ad1b63b6..01f365a1 100644 --- a/api/v1alpha1/seinode_types.go +++ b/api/v1alpha1/seinode_types.go @@ -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 ..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 diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go index df444c6d..f3499f06 100644 --- a/api/v1alpha1/zz_generated.deepcopy.go +++ b/api/v1alpha1/zz_generated.deepcopy.go @@ -501,6 +501,21 @@ func (in *NodeEndpoint) DeepCopy() *NodeEndpoint { return out } +// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. +func (in *NodeEndpointStatus) DeepCopyInto(out *NodeEndpointStatus) { + *out = *in +} + +// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new NodeEndpointStatus. +func (in *NodeEndpointStatus) DeepCopy() *NodeEndpointStatus { + if in == nil { + return nil + } + out := new(NodeEndpointStatus) + 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 @@ -1073,6 +1088,11 @@ func (in *SeiNodeStatus) DeepCopyInto(out *SeiNodeStatus) { *out = new(StatefulSetRef) **out = **in } + if in.Endpoint != nil { + in, out := &in.Endpoint, &out.Endpoint + *out = new(NodeEndpointStatus) + **out = **in + } } // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new SeiNodeStatus. diff --git a/config/crd/sei.io_seinodes.yaml b/config/crd/sei.io_seinodes.yaml index 97d210f4..70a8c1a7 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -821,6 +821,39 @@ 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 set API.REST.Enable=false — + sei-config defaults.go:264). + 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 — note validators DO bind RPC on 0.0.0.0:26657 + (sei-config defaults.go:260), we simply don't advertise it. + type: string + type: object phase: description: Phase is the high-level lifecycle state. enum: diff --git a/internal/controller/node/controller.go b/internal/controller/node/controller.go index 1d2f2fb6..86a7899c 100644 --- a/internal/controller/node/controller.go +++ b/internal/controller/node/controller.go @@ -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 { diff --git a/internal/controller/node/endpoints.go b/internal/controller/node/endpoints.go new file mode 100644 index 00000000..68f7e2f0 --- /dev/null +++ b/internal/controller/node/endpoints.go @@ -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 (..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) +} diff --git a/internal/controller/node/endpoints_test.go b/internal/controller/node/endpoints_test.go new file mode 100644 index 00000000..b77d8047 --- /dev/null +++ b/internal/controller/node/endpoints_test.go @@ -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()) +} diff --git a/internal/controller/node/reconciler_test.go b/internal/controller/node/reconciler_test.go index 6f547bea..2653a0b3 100644 --- a/internal/controller/node/reconciler_test.go +++ b/internal/controller/node/reconciler_test.go @@ -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() diff --git a/manifests/sei.io_seinodes.yaml b/manifests/sei.io_seinodes.yaml index 97d210f4..70a8c1a7 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -821,6 +821,39 @@ 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 set API.REST.Enable=false — + sei-config defaults.go:264). + 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 — note validators DO bind RPC on 0.0.0.0:26657 + (sei-config defaults.go:260), we simply don't advertise it. + type: string + type: object phase: description: Phase is the high-level lifecycle state. enum: From b3e9e06a3ed3ff9a7235d25b2078c17b5b577ee8 Mon Sep 17 00:00:00 2001 From: bdchatham Date: Thu, 18 Jun 2026 12:20:05 -0700 Subject: [PATCH 2/2] chore: regenerate SeiNode CRD after status doc-comment edit CRD descriptions are generated from the Go doc comments; regenerate so verify-generated matches the source. Co-Authored-By: Claude Opus 4.8 --- config/crd/sei.io_seinodes.yaml | 7 +++---- manifests/sei.io_seinodes.yaml | 7 +++---- 2 files changed, 6 insertions(+), 8 deletions(-) diff --git a/config/crd/sei.io_seinodes.yaml b/config/crd/sei.io_seinodes.yaml index 70a8c1a7..fc128267 100644 --- a/config/crd/sei.io_seinodes.yaml +++ b/config/crd/sei.io_seinodes.yaml @@ -843,15 +843,14 @@ spec: tendermintRest: description: |- TendermintRest is the Cosmos REST (LCD) URL (http://). Served only by - fullNode/archive (validators set API.REST.Enable=false — - sei-config defaults.go:264). + 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 — note validators DO bind RPC on 0.0.0.0:26657 - (sei-config defaults.go:260), we simply don't advertise it. + validator/replayer — validators do bind RPC on 0.0.0.0 but we don't + advertise it. type: string type: object phase: diff --git a/manifests/sei.io_seinodes.yaml b/manifests/sei.io_seinodes.yaml index 70a8c1a7..fc128267 100644 --- a/manifests/sei.io_seinodes.yaml +++ b/manifests/sei.io_seinodes.yaml @@ -843,15 +843,14 @@ spec: tendermintRest: description: |- TendermintRest is the Cosmos REST (LCD) URL (http://). Served only by - fullNode/archive (validators set API.REST.Enable=false — - sei-config defaults.go:264). + 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 — note validators DO bind RPC on 0.0.0.0:26657 - (sei-config defaults.go:260), we simply don't advertise it. + validator/replayer — validators do bind RPC on 0.0.0.0 but we don't + advertise it. type: string type: object phase: