Skip to content

Commit 3b89217

Browse files
bdchathamclaude
andauthored
fix: gRPC appProtocol and EVM WebSocket routing (#71)
* fix: gRPC appProtocol and EVM WebSocket routing Two networking fixes from expert validation: 1. gRPC: set appProtocol "kubernetes.io/h2c" on the gRPC ServicePort so Envoy uses HTTP/2 to the backend. Without this, Envoy defaults to HTTP/1.1 and gRPC requests fail. 2. EVM WebSocket: add a second rule to the EVM HTTPRoute with a header match for "Upgrade: websocket" routing to port 8546. seid serves JSON-RPC on 8545 and WebSocket on 8546 as separate ports. The first rule (default) routes HTTP to 8545, the second routes WebSocket upgrades to 8546. Both share the same hostname, matching the industry standard (Alchemy, Infura use one URL for HTTP + WS). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix: set gRPC appProtocol on headless per-node Service Set appProtocol "kubernetes.io/h2c" on the gRPC port in the headless per-node Service (servicePorts), matching what's already set on the shared external Service. Ensures Istio/Envoy uses HTTP/2 for gRPC when routing directly to individual pods. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * test: add edge case coverage and remove breadcrumb comment Add tests for: - Empty domain produces malformed hostnames (documents the invariant that platform.Validate() catches at startup) - Validator mode produces zero routes - Non-EVM routes have exactly one rule and zero WSPort Remove breadcrumb comment in BackendRef test. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 5b28288 commit 3b89217

3 files changed

Lines changed: 161 additions & 14 deletions

File tree

internal/controller/node/resources.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,10 @@ func servicePorts() []corev1.ServicePort {
343343
ports := make([]corev1.ServicePort, len(np))
344344
for i, p := range np {
345345
ports[i] = corev1.ServicePort{Name: p.Name, Port: p.Port, TargetPort: intstr.FromInt32(p.Port), Protocol: corev1.ProtocolTCP}
346+
if p.Name == "grpc" {
347+
h2c := "kubernetes.io/h2c"
348+
ports[i].AppProtocol = &h2c
349+
}
346350
}
347351
return ports
348352
}

internal/controller/nodedeployment/networking.go

Lines changed: 44 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ type effectiveRoute struct {
3636
Name string
3737
Hostnames []string
3838
Port int32
39+
WSPort int32 // non-zero when WebSocket requires a separate backend port
3940
}
4041

4142
// hasExternalService returns true when the deployment has a LoadBalancer
@@ -198,6 +199,10 @@ func portsForMode(mode seiconfig.NodeMode) []corev1.ServicePort {
198199
TargetPort: intstr.FromInt32(p.Port),
199200
Protocol: corev1.ProtocolTCP,
200201
}
202+
if p.Name == "grpc" {
203+
h2c := "kubernetes.io/h2c"
204+
ports[i].AppProtocol = &h2c
205+
}
201206
}
202207
return ports
203208
}
@@ -242,11 +247,15 @@ func resolveEffectiveRoutes(group *seiv1alpha1.SeiNodeDeployment, domain string)
242247
if !isProtocolActiveForMode(proto.Prefix, activePorts) {
243248
continue
244249
}
245-
routes = append(routes, effectiveRoute{
250+
er := effectiveRoute{
246251
Name: fmt.Sprintf("%s-%s", group.Name, proto.Prefix),
247252
Hostnames: []string{fmt.Sprintf("%s.%s.%s", group.Name, proto.Prefix, domain)},
248253
Port: proto.Port,
249-
})
254+
}
255+
if proto.Prefix == "evm" && activePorts["evm-ws"] {
256+
er.WSPort = seiconfig.PortEVMWS
257+
}
258+
routes = append(routes, er)
250259
}
251260
return routes
252261
}
@@ -331,6 +340,38 @@ func generateHTTPRoute(group *seiv1alpha1.SeiNodeDeployment, er effectiveRoute,
331340
"namespace": gatewayNamespace,
332341
}
333342

343+
rules := []any{
344+
map[string]any{
345+
"backendRefs": []any{
346+
map[string]any{
347+
"name": svcName,
348+
"port": int64(er.Port),
349+
},
350+
},
351+
},
352+
}
353+
if er.WSPort != 0 {
354+
rules = append(rules, map[string]any{
355+
"matches": []any{
356+
map[string]any{
357+
"headers": []any{
358+
map[string]any{
359+
"type": "Exact",
360+
"name": "Upgrade",
361+
"value": "websocket",
362+
},
363+
},
364+
},
365+
},
366+
"backendRefs": []any{
367+
map[string]any{
368+
"name": svcName,
369+
"port": int64(er.WSPort),
370+
},
371+
},
372+
})
373+
}
374+
334375
route := &unstructured.Unstructured{
335376
Object: map[string]any{
336377
"apiVersion": "gateway.networking.k8s.io/v1",
@@ -344,16 +385,7 @@ func generateHTTPRoute(group *seiv1alpha1.SeiNodeDeployment, er effectiveRoute,
344385
"spec": map[string]any{
345386
"parentRefs": []any{parentRef},
346387
"hostnames": hostnames,
347-
"rules": []any{
348-
map[string]any{
349-
"backendRefs": []any{
350-
map[string]any{
351-
"name": svcName,
352-
"port": int64(er.Port),
353-
},
354-
},
355-
},
356-
},
388+
"rules": rules,
357389
},
358390
},
359391
}

internal/controller/nodedeployment/networking_test.go

Lines changed: 113 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,24 @@ func TestGenerateExternalService_ValidatorModePorts(t *testing.T) {
5858
g.Expect(portNames).To(ConsistOf("p2p", "metrics"))
5959
}
6060

61+
func TestGenerateExternalService_GRPCAppProtocol(t *testing.T) {
62+
g := NewWithT(t)
63+
group := newTestGroup("pacific-1-rpc", "sei")
64+
group.Spec.Networking = &seiv1alpha1.NetworkingConfig{
65+
Service: &seiv1alpha1.ExternalServiceConfig{},
66+
}
67+
68+
svc := generateExternalService(group)
69+
for _, p := range svc.Spec.Ports {
70+
if p.Name == "grpc" {
71+
g.Expect(p.AppProtocol).NotTo(BeNil())
72+
g.Expect(*p.AppProtocol).To(Equal("kubernetes.io/h2c"))
73+
return
74+
}
75+
}
76+
t.Fatal("grpc port not found")
77+
}
78+
6179
func TestGenerateExternalService_Annotations(t *testing.T) {
6280
g := NewWithT(t)
6381
group := newTestGroup("archive-rpc", "sei")
@@ -202,21 +220,60 @@ func TestGenerateHTTPRoute_EVMMerged(t *testing.T) {
202220

203221
routes := resolveEffectiveRoutes(group, "prod.platform.sei.io")
204222

223+
var evmRoute effectiveRoute
205224
evmCount := 0
206225
for _, r := range routes {
207226
if r.Name == "pacific-1-rpc-evm" {
208227
evmCount++
209-
g.Expect(r.Port).To(Equal(int32(8545)))
228+
evmRoute = r
210229
}
211230
}
212231
g.Expect(evmCount).To(Equal(1), "expected exactly one merged EVM route")
232+
g.Expect(evmRoute.Port).To(Equal(int32(8545)))
233+
g.Expect(evmRoute.WSPort).To(Equal(int32(8546)))
213234

214235
for _, r := range routes {
215236
g.Expect(r.Name).NotTo(ContainSubstring("evm-rpc"))
216237
g.Expect(r.Name).NotTo(ContainSubstring("evm-ws"))
217238
}
218239
}
219240

241+
func TestGenerateHTTPRoute_EVMWebSocketRule(t *testing.T) {
242+
g := NewWithT(t)
243+
group := newTestGroup("pacific-1-rpc", "sei")
244+
group.Spec.Networking = &seiv1alpha1.NetworkingConfig{
245+
Service: &seiv1alpha1.ExternalServiceConfig{},
246+
}
247+
248+
routes := resolveEffectiveRoutes(group, "prod.platform.sei.io")
249+
var evmRoute effectiveRoute
250+
for _, r := range routes {
251+
if r.Name == "pacific-1-rpc-evm" {
252+
evmRoute = r
253+
break
254+
}
255+
}
256+
257+
httpRoute := generateHTTPRoute(group, evmRoute, "sei-gateway", "gateway")
258+
spec := httpRoute.Object["spec"].(map[string]any)
259+
rules := spec["rules"].([]any)
260+
g.Expect(rules).To(HaveLen(2), "EVM route should have HTTP + WebSocket rules")
261+
262+
httpRule := rules[0].(map[string]any)
263+
httpBackend := httpRule["backendRefs"].([]any)[0].(map[string]any)
264+
g.Expect(httpBackend["port"]).To(Equal(int64(8545)))
265+
266+
wsRule := rules[1].(map[string]any)
267+
wsMatches := wsRule["matches"].([]any)
268+
wsHeaders := wsMatches[0].(map[string]any)["headers"].([]any)
269+
wsHeader := wsHeaders[0].(map[string]any)
270+
g.Expect(wsHeader["name"]).To(Equal("Upgrade"))
271+
g.Expect(wsHeader["value"]).To(Equal("websocket"))
272+
273+
wsBackend := wsRule["backendRefs"].([]any)[0].(map[string]any)
274+
g.Expect(wsBackend["port"]).To(Equal(int64(8546)))
275+
}
276+
220277
// --- HTTPRoute Generation ---
221278

222279
func TestGenerateHTTPRoute_BasicFields(t *testing.T) {
@@ -264,7 +321,14 @@ func TestGenerateHTTPRoute_BackendRef(t *testing.T) {
264321
}
265322

266323
routes := resolveEffectiveRoutes(group, "prod.platform.sei.io")
267-
route := generateHTTPRoute(group, routes[0], "sei-gateway", "istio-system")
324+
var rpcRoute effectiveRoute
325+
for _, r := range routes {
326+
if r.Name == "archive-rpc-rpc" {
327+
rpcRoute = r
328+
break
329+
}
330+
}
331+
route := generateHTTPRoute(group, rpcRoute, "sei-gateway", "istio-system")
268332

269333
spec := route.Object["spec"].(map[string]any)
270334
rules := spec["rules"].([]any)
@@ -319,6 +383,53 @@ func TestIsProtocolActiveForMode_EVMMapping(t *testing.T) {
319383
g.Expect(isProtocolActiveForMode("grpc", activePorts)).To(BeFalse())
320384
}
321385

386+
// --- Edge Cases ---
387+
388+
func TestResolveEffectiveRoutes_EmptyDomain_MalformedHostnames(t *testing.T) {
389+
g := NewWithT(t)
390+
group := newTestGroup("pacific-1-rpc", "sei")
391+
group.Spec.Networking = &seiv1alpha1.NetworkingConfig{
392+
Service: &seiv1alpha1.ExternalServiceConfig{},
393+
}
394+
395+
routes := resolveEffectiveRoutes(group, "")
396+
g.Expect(routes).To(HaveLen(4), "routes are still generated even with empty domain")
397+
g.Expect(routes[0].Hostnames[0]).To(Equal("pacific-1-rpc.evm."), "empty domain produces trailing dot")
398+
}
399+
400+
func TestReconcileRoute_NoRoutesForValidatorMode(t *testing.T) {
401+
g := NewWithT(t)
402+
group := newTestGroup("pacific-1-val", "sei")
403+
group.Spec.Template.Spec.Validator = &seiv1alpha1.ValidatorSpec{}
404+
group.Spec.Networking = &seiv1alpha1.NetworkingConfig{
405+
Service: &seiv1alpha1.ExternalServiceConfig{},
406+
}
407+
408+
routes := resolveEffectiveRoutes(group, "prod.platform.sei.io")
409+
g.Expect(routes).To(BeEmpty(), "validator mode should produce zero routes")
410+
}
411+
412+
func TestGenerateHTTPRoute_NonEVMRoute_SingleRule(t *testing.T) {
413+
g := NewWithT(t)
414+
group := newTestGroup("pacific-1-rpc", "sei")
415+
group.Spec.Networking = &seiv1alpha1.NetworkingConfig{
416+
Service: &seiv1alpha1.ExternalServiceConfig{},
417+
}
418+
419+
routes := resolveEffectiveRoutes(group, "prod.platform.sei.io")
420+
for _, r := range routes {
421+
if r.Name == "pacific-1-rpc-rpc" {
422+
httpRoute := generateHTTPRoute(group, r, "sei-gateway", "gateway")
423+
spec := httpRoute.Object["spec"].(map[string]any)
424+
rules := spec["rules"].([]any)
425+
g.Expect(rules).To(HaveLen(1), "non-EVM routes should have exactly one rule")
426+
g.Expect(r.WSPort).To(Equal(int32(0)), "non-EVM routes should have zero WSPort")
427+
return
428+
}
429+
}
430+
t.Fatal("rpc route not found")
431+
}
432+
322433
// --- AuthorizationPolicy ---
323434

324435
func TestGenerateAuthorizationPolicy_BasicStructure(t *testing.T) {

0 commit comments

Comments
 (0)