feat(sdk): route Go SDK exec and forward to the sandbox owner replica (#4852)

* feat(sdk): route Go SDK exec and forward to the sandbox owner replica

The Go SDK now does what the CLI already does for long-lived sandbox
calls. The GetSandbox check that Exec.Run, Exec.Stream, Exec.Interactive,
TCP.Forward and SSH.Tunnel already make also asks the gateway for the
owner replica. The call then sends it in the x-openshell-replica header
and retries once without the header when the edge proxy reports that the
owner's route has no ready endpoints.

No extra round trip is added. Without a reported owner the calls are
unchanged.

Part of #4223

Signed-off-by: divesh <dgude@nvidia.com>

* refactor(sdk): address Go replica routing review

Share the header check between the routed attempt and its retry, keep the
first-frame EOF rule in one bidi helper, reuse Get's request for the owner
lookup, and document the header contract next to the replica routing docs.

Part of #4223

Signed-off-by: divesh <dgude@nvidia.com>

* docs(kubernetes): refer to the SDKs collectively in replica routing

Part of #4223

Signed-off-by: divesh <dgude@nvidia.com>

---------

Signed-off-by: divesh <dgude@nvidia.com>
This commit is contained in:
Divesh
2026-10-29 17:18:41 +00:00
committed by GitHub
parent 077d43131e
commit cc9995438f
7 changed files with 335 additions and 35 deletions
+1 -1
View File
@@ -257,7 +257,7 @@ helm upgrade <release-name> oci://ghcr.io/nvidia/openshell/helm-chart \
--set grpcRoute.replicaRouting.enabled=true
```
The chart renders one Service per replica and a GRPCRoute rule that matches the `x-openshell-replica` request header. The CLI and the Rust SDK set that header from the owner the gateway reports and retry without it if the named replica is unavailable. Replica routing requires `workload.kind=statefulset`, `workload.allowMultiReplicaStatefulSet=true`, and at most 15 replicas.
The chart renders one Service per replica and a GRPCRoute rule that matches the `x-openshell-replica` request header. The CLI and SDKs send `x-openshell-want-owner: 1` on `GetSandbox`, read the owner from the `x-openshell-owner` response header, and set it as `x-openshell-replica`. If the proxy answers `UNAVAILABLE` with an empty message, `no healthy upstream`, or `connection failure`, they retry once without it. Replica routing requires `workload.kind=statefulset`, `workload.allowMultiReplicaStatefulSet=true`, and at most 15 replicas.
If the release uses `workload.kind=deployment`, this upgrade replaces the Deployment with a StatefulSet, so every gateway pod restarts at once. The StatefulSet also requests a 1Gi PersistentVolumeClaim per replica, which stays `Pending` on clusters without a default StorageClass.
+17 -15
View File
@@ -27,7 +27,8 @@ func (e *execClient) Run(ctx context.Context, workspace, sandboxName string, com
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
owner, err := sandboxOwner(ctx, e.sandboxes, workspace, sandboxName)
if err != nil {
return nil, err
}
var opt *ExecOptions
@@ -37,7 +38,9 @@ func (e *execClient) Run(ctx context.Context, workspace, sandboxName string, com
req := converter.ExecRequestToProto(sandboxName, command, opt)
req.WorkspaceScope = namedWorkspaceScope(workspace)
stream, err := e.client.ExecSandbox(ctx, req)
stream, err := openRouted(ctx, owner, func(ctx context.Context) (grpc.ServerStreamingClient[pb.ExecSandboxEvent], error) {
return e.client.ExecSandbox(ctx, req)
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
@@ -61,7 +64,8 @@ func (e *execClient) Stream(ctx context.Context, workspace, sandboxName string,
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
owner, err := sandboxOwner(ctx, e.sandboxes, workspace, sandboxName)
if err != nil {
return nil, err
}
var opt *ExecOptions
@@ -72,7 +76,9 @@ func (e *execClient) Stream(ctx context.Context, workspace, sandboxName string,
req.WorkspaceScope = namedWorkspaceScope(workspace)
streamCtx, cancel := context.WithCancel(ctx)
stream, err := e.client.ExecSandbox(streamCtx, req)
stream, err := openRouted(streamCtx, owner, func(ctx context.Context) (grpc.ServerStreamingClient[pb.ExecSandboxEvent], error) {
return e.client.ExecSandbox(ctx, req)
})
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
@@ -85,7 +91,8 @@ func (e *execClient) Interactive(ctx context.Context, workspace, sandboxName str
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
owner, err := sandboxOwner(ctx, e.sandboxes, workspace, sandboxName)
if err != nil {
return nil, err
}
var opt *ExecOptions
@@ -93,22 +100,17 @@ func (e *execClient) Interactive(ctx context.Context, workspace, sandboxName str
opt = &opts[0]
}
startReq := converter.ExecInteractiveRequestToProto(sandboxName, command, cols, rows, opt)
startReq.WorkspaceScope = namedWorkspaceScope(workspace)
streamCtx, cancel := context.WithCancel(ctx)
stream, err := e.client.ExecSandboxInteractive(streamCtx)
start := &pb.ExecSandboxInput{Payload: &pb.ExecSandboxInput_Start{Start: startReq}}
stream, err := openBidiRouted(streamCtx, owner, start, e.client.ExecSandboxInteractive)
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
startReq := converter.ExecInteractiveRequestToProto(sandboxName, command, cols, rows, opt)
startReq.WorkspaceScope = namedWorkspaceScope(workspace)
if sendErr := stream.Send(&pb.ExecSandboxInput{
Payload: &pb.ExecSandboxInput_Start{Start: startReq},
}); sendErr != nil {
cancel()
return nil, converter.FromGRPCError(sendErr)
}
return newInteractiveSession(streamCtx, cancel, stream), nil
}
+95
View File
@@ -0,0 +1,95 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"io"
"strings"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
)
// Metadata that lets an edge proxy send a sandbox's long-lived calls straight
// to the gateway replica holding its supervisor session. The headers and the
// retry rule are documented in docs/kubernetes/ingress.mdx.
const (
ownerReplicaHeader = "x-openshell-owner"
ownerRequestHeader = "x-openshell-want-owner"
routeReplicaHeader = "x-openshell-replica"
)
// ownerGetter is implemented by the gateway-backed sandbox client.
type ownerGetter interface {
getWithOwner(ctx context.Context, workspace, name string) (*Sandbox, string, error)
}
// sandboxOwner checks that the sandbox exists and returns the replica that
// owns its supervisor session, or "" when the gateway reports none.
func sandboxOwner(ctx context.Context, sandboxes SandboxInterface, workspace, name string) (string, error) {
if g, ok := sandboxes.(ownerGetter); ok {
_, owner, err := g.getWithOwner(ctx, workspace, name)
return owner, err
}
_, err := sandboxes.Get(ctx, workspace, name)
return "", err
}
// openRouted opens a stream tagged with the owner replica. It opens once more
// untagged when the proxy reports that the owner's route has no ready endpoints.
// A stale owner is safe: any replica relays the call to the real owner.
// With an owner, it returns only after the gateway answers, as the CLI does.
func openRouted[S grpc.ClientStream](ctx context.Context, owner string, open func(context.Context) (S, error)) (S, error) {
if owner == "" {
return open(ctx)
}
stream, err := answered(open(metadata.AppendToOutgoingContext(ctx, routeReplicaHeader, owner)))
if retryUnrouted(err) {
return answered(open(ctx))
}
return stream, err
}
// answered waits for the response headers. Nil metadata means the stream ended
// without headers, and RecvMsg then returns its status.
func answered[S grpc.ClientStream](stream S, err error) (S, error) {
if err == nil {
var md metadata.MD
if md, err = stream.Header(); err == nil && md == nil {
err = stream.RecvMsg(nil)
}
}
return stream, err
}
// openBidiRouted is openRouted for a bidi stream whose first frame is sent on open.
func openBidiRouted[I, O any](ctx context.Context, owner string, first *I, open func(context.Context, ...grpc.CallOption) (grpc.BidiStreamingClient[I, O], error)) (grpc.BidiStreamingClient[I, O], error) {
return openRouted(ctx, owner, func(ctx context.Context) (grpc.BidiStreamingClient[I, O], error) {
s, err := open(ctx)
if err != nil {
return nil, err
}
// io.EOF means the stream already ended; answered reports its status.
if err := s.Send(first); err == io.EOF {
return answered(s, nil)
} else if err != nil {
return s, err
}
return s, nil
})
}
// retryUnrouted matches what Envoy Gateway answers for a replica route with no
// ready endpoints: UNAVAILABLE with no message. Gateway errors always carry one.
func retryUnrouted(err error) bool {
s, ok := status.FromError(err)
if !ok || s.Code() != codes.Unavailable {
return false
}
msg := s.Message()
return msg == "" || strings.Contains(msg, "no healthy upstream") || strings.Contains(msg, "connection failure")
}
+192
View File
@@ -0,0 +1,192 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"io"
"net"
"sync"
"testing"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
"google.golang.org/grpc/status"
"google.golang.org/grpc/test/bufconn"
)
// routingServer reports owner from GetSandbox and records the replica header
// of every streaming call ("" when unrouted). Routed calls fail with routedErr.
type routingServer struct {
pb.UnimplementedOpenShellServer
owner string
routedErr error
mu sync.Mutex
wantedBy []string
routes []string
}
func (s *routingServer) GetSandbox(ctx context.Context, _ *pb.GetSandboxRequest) (*pb.SandboxResponse, error) {
md, _ := metadata.FromIncomingContext(ctx)
s.mu.Lock()
s.wantedBy = append(s.wantedBy, firstValue(md, ownerRequestHeader))
s.mu.Unlock()
if s.owner != "" {
_ = grpc.SetHeader(ctx, metadata.Pairs(ownerReplicaHeader, s.owner))
}
return &pb.SandboxResponse{Sandbox: &pb.Sandbox{}}, nil
}
// route records the call and returns the error a routed call should fail with.
func (s *routingServer) route(ctx context.Context) error {
md, _ := metadata.FromIncomingContext(ctx)
replica := firstValue(md, routeReplicaHeader)
s.mu.Lock()
defer s.mu.Unlock()
s.routes = append(s.routes, replica)
if replica != "" {
return s.routedErr
}
return nil
}
func (s *routingServer) ExecSandbox(_ *pb.ExecSandboxRequest, stream grpc.ServerStreamingServer[pb.ExecSandboxEvent]) error {
if err := s.route(stream.Context()); err != nil {
return err
}
return stream.Send(exitEvent())
}
func (s *routingServer) ExecSandboxInteractive(stream grpc.BidiStreamingServer[pb.ExecSandboxInput, pb.ExecSandboxEvent]) error {
if _, err := stream.Recv(); err != nil {
return err
}
if err := s.route(stream.Context()); err != nil {
return err
}
return stream.Send(exitEvent())
}
func (s *routingServer) ForwardTcp(stream grpc.BidiStreamingServer[pb.TcpForwardFrame, pb.TcpForwardFrame]) error { //nolint:revive // proto-generated method name
if _, err := stream.Recv(); err != nil {
return err
}
if err := s.route(stream.Context()); err != nil {
return err
}
return stream.Send(&pb.TcpForwardFrame{Payload: &pb.TcpForwardFrame_Data{Data: []byte("pong")}})
}
func exitEvent() *pb.ExecSandboxEvent {
return &pb.ExecSandboxEvent{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{}}}
}
func firstValue(md metadata.MD, key string) string {
if v := md.Get(key); len(v) > 0 {
return v[0]
}
return ""
}
func routingClients(t *testing.T, srv *routingServer) (*execClient, *tcpClient) {
t.Helper()
lis := bufconn.Listen(bufSize)
gs := grpc.NewServer()
pb.RegisterOpenShellServer(gs, srv)
go func() { _ = gs.Serve(lis) }()
conn, err := grpc.NewClient("passthrough:///bufconn",
grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return lis.Dial() }),
grpc.WithTransportCredentials(insecure.NewCredentials()),
)
require.NoError(t, err)
t.Cleanup(func() {
_ = conn.Close()
gs.Stop()
})
sandboxes := newSandboxClient(conn)
return newExecClient(conn, sandboxes), newTCPClient(conn, sandboxes, nil)
}
func TestExecRoutesToOwner(t *testing.T) {
srv := &routingServer{owner: "openshell-1"}
exec, _ := routingClients(t, srv)
_, err := exec.Run(context.Background(), "default", "box", []string{"true"})
require.NoError(t, err)
assert.Equal(t, []string{"1"}, srv.wantedBy)
assert.Equal(t, []string{"openshell-1"}, srv.routes)
}
func TestExecWithoutOwnerIsUnrouted(t *testing.T) {
srv := &routingServer{}
exec, _ := routingClients(t, srv)
_, err := exec.Run(context.Background(), "default", "box", []string{"true"})
require.NoError(t, err)
assert.Equal(t, []string{""}, srv.routes)
}
func TestRoutedCallsRetryOnceWithoutHintWhenOwnerHasNoEndpoints(t *testing.T) {
srv := &routingServer{owner: "openshell-1", routedErr: status.Error(codes.Unavailable, "")}
exec, tcp := routingClients(t, srv)
ctx := context.Background()
_, err := exec.Run(ctx, "default", "box", []string{"true"})
require.NoError(t, err)
stream, err := exec.Stream(ctx, "default", "box", []string{"true"})
require.NoError(t, err)
code, err := stream.ExitCode()
require.NoError(t, err)
assert.Zero(t, code)
session, err := exec.Interactive(ctx, "default", "box", []string{"sh"}, 80, 24)
require.NoError(t, err)
_, err = session.ExitCode()
require.NoError(t, err)
conn, err := tcp.Forward(ctx, "default", "box", 8080)
require.NoError(t, err)
buf := make([]byte, 4)
_, err = io.ReadFull(conn, buf)
require.NoError(t, err)
assert.Equal(t, "pong", string(buf))
_ = conn.Close()
// Each call is attempted routed, then once unrouted.
assert.Equal(t, []string{
"openshell-1", "", "openshell-1", "", "openshell-1", "", "openshell-1", "",
}, srv.routes)
}
func TestRoutedCallDoesNotRetryOnGatewayError(t *testing.T) {
srv := &routingServer{owner: "openshell-1", routedErr: status.Error(codes.Unavailable, "supervisor not connected")}
exec, tcp := routingClients(t, srv)
ctx := context.Background()
_, err := exec.Run(ctx, "default", "box", []string{"true"})
require.Error(t, err)
_, err = tcp.Forward(ctx, "default", "box", 8080)
require.Error(t, err)
assert.Equal(t, []string{"openshell-1", "openshell-1"}, srv.routes)
}
func TestRetryUnrouted(t *testing.T) {
assert.True(t, retryUnrouted(status.Error(codes.Unavailable, "")))
assert.True(t, retryUnrouted(status.Error(codes.Unavailable, "no healthy upstream")))
assert.True(t, retryUnrouted(status.Error(codes.Unavailable, "upstream connect error: connection failure")))
assert.False(t, retryUnrouted(status.Error(codes.Unavailable, "supervisor not connected")))
assert.False(t, retryUnrouted(status.Error(codes.NotFound, "")))
assert.False(t, retryUnrouted(io.EOF))
assert.False(t, retryUnrouted(nil))
}
+20 -1
View File
@@ -13,6 +13,7 @@ import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"google.golang.org/grpc"
"google.golang.org/grpc/metadata"
)
const defaultPollInterval = 500 * time.Millisecond
@@ -116,10 +117,28 @@ func validateTemplateCreateSpec(spec *SandboxSpec) error {
}
func (s *sandboxClient) Get(ctx context.Context, workspace, name string) (*Sandbox, error) {
return s.get(ctx, workspace, name)
}
// getWithOwner is Get that also asks the gateway for the owner replica.
func (s *sandboxClient) getWithOwner(ctx context.Context, workspace, name string) (*Sandbox, string, error) {
var md metadata.MD
sb, err := s.get(metadata.AppendToOutgoingContext(ctx, ownerRequestHeader, "1"), workspace, name, grpc.Header(&md))
if err != nil {
return nil, "", err
}
var owner string
if v := md.Get(ownerReplicaHeader); len(v) > 0 {
owner = v[0]
}
return sb, owner, nil
}
func (s *sandboxClient) get(ctx context.Context, workspace, name string, opts ...grpc.CallOption) (*Sandbox, error) {
resp, err := s.client.GetSandbox(ctx, &pb.GetSandboxRequest{
Name: name,
WorkspaceScope: namedWorkspaceScope(workspace),
})
}, opts...)
if err != nil {
return nil, converter.FromGRPCError(err)
}
+5 -9
View File
@@ -65,7 +65,8 @@ func (s *sshClient) Tunnel(ctx context.Context, workspace, sandboxName string, p
Message: fmt.Sprintf("port must be in range 1-65535, got %d", port),
}
}
if _, err := s.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
owner, err := sandboxOwner(ctx, s.sandboxes, workspace, sandboxName)
if err != nil {
return nil, err
}
@@ -84,13 +85,6 @@ func (s *sshClient) Tunnel(ctx context.Context, workspace, sandboxName string, p
}
}()
streamCtx, cancel := context.WithCancel(ctx)
stream, err := s.client.ForwardTcp(streamCtx)
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
initFrame := &pb.TcpForwardFrame{
Payload: &pb.TcpForwardFrame_Init{
Init: &pb.TcpForwardInit{
@@ -105,7 +99,9 @@ func (s *sshClient) Tunnel(ctx context.Context, workspace, sandboxName string, p
},
}
if err := stream.Send(initFrame); err != nil {
streamCtx, cancel := context.WithCancel(ctx)
stream, err := openBidiRouted(streamCtx, owner, initFrame, s.client.ForwardTcp) //nolint:revive // proto-generated method name
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
+5 -9
View File
@@ -37,20 +37,14 @@ func (t *tcpClient) Forward(ctx context.Context, workspace, sandboxName string,
Message: fmt.Sprintf("port must be in range 1-65535, got %d", port),
}
}
if _, err := t.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
owner, err := sandboxOwner(ctx, t.sandboxes, workspace, sandboxName)
if err != nil {
return nil, err
}
var cfg forwardConfig
options.Apply(&cfg, opts)
streamCtx, cancel := context.WithCancel(ctx)
stream, err := t.client.ForwardTcp(streamCtx)
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
initFrame := &pb.TcpForwardFrame{
Payload: &pb.TcpForwardFrame_Init{
Init: &pb.TcpForwardInit{
@@ -67,7 +61,9 @@ func (t *tcpClient) Forward(ctx context.Context, workspace, sandboxName string,
},
}
if err := stream.Send(initFrame); err != nil {
streamCtx, cancel := context.WithCancel(ctx)
stream, err := openBidiRouted(streamCtx, owner, initFrame, t.client.ForwardTcp) //nolint:revive // proto-generated method name
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}