Files
OpenShell/sdk/go/openshell/v1/exec_client.go
Drew Newberry d91b1999a0 feat(api)!: use sandbox names as canonical RPC references (#3272)
* feat(api)!: use sandbox names as canonical references

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* test(cli): update forward color fixture for workspace scope

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(supervisor): use sandbox names for settings lookup

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* test(e2e): use canonical sandbox request fields

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* test(e2e): use canonical sandbox receipt field

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(server): harden sandbox mutation handling

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(ci): update rebased sandbox references

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* feat(api)!: standardize canonical entity references

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* docs(api): codify protobuf API conventions

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(api): preserve workspace selector semantics

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(api): restore workspace selector parity

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(api): preserve descriptive name fields

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(api): update e2e request fixtures

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(core): omit workspace selector during bootstrap

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* chore(api): remove proto convention checker

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* chore(api): refresh schema fingerprints after rebase

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

* fix(cli): use canonical provider receipt field

Signed-off-by: Drew Newberry <anewberry@nvidia.com>

---------

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
2026-09-18 11:39:55 -07:00

332 lines
7.6 KiB
Go

// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"io"
"sync"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"google.golang.org/grpc"
)
type execClient struct {
client pb.OpenShellClient
sandboxes SandboxInterface
}
func newExecClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *execClient {
return &execClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes}
}
func (e *execClient) Run(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error) {
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
return nil, err
}
var opt *ExecOptions
if len(opts) > 0 {
opt = &opts[0]
}
req := converter.ExecRequestToProto(sandboxName, command, opt)
req.WorkspaceScope = namedWorkspaceScope(workspace)
stream, err := e.client.ExecSandbox(ctx, req)
if err != nil {
return nil, converter.FromGRPCError(err)
}
var events []*pb.ExecSandboxEvent
for {
ev, recvErr := stream.Recv()
if recvErr == io.EOF {
break
}
if recvErr != nil {
return nil, converter.FromGRPCError(recvErr)
}
events = append(events, ev)
}
return converter.ExecResultFromEvents(events)
}
func (e *execClient) Stream(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error) {
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
return nil, err
}
var opt *ExecOptions
if len(opts) > 0 {
opt = &opts[0]
}
req := converter.ExecRequestToProto(sandboxName, command, opt)
req.WorkspaceScope = namedWorkspaceScope(workspace)
streamCtx, cancel := context.WithCancel(ctx)
stream, err := e.client.ExecSandbox(streamCtx, req)
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
return &execStream{stream: stream, cancel: cancel}, nil
}
func (e *execClient) Interactive(ctx context.Context, workspace, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error) {
if sandboxName == "" {
return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"}
}
if _, err := e.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
return nil, err
}
var opt *ExecOptions
if len(opts) > 0 {
opt = &opts[0]
}
streamCtx, cancel := context.WithCancel(ctx)
stream, err := e.client.ExecSandboxInteractive(streamCtx)
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
}
// execStream wraps a server-streaming RPC into the ExecStream interface.
type execStream struct {
stream grpc.ServerStreamingClient[pb.ExecSandboxEvent]
cancel context.CancelFunc
exitCode int
exited bool
hasExit bool
}
func (s *execStream) Next() (*ExecChunk, error) {
if s.exited {
return nil, io.EOF
}
ev, err := s.stream.Recv()
if err == io.EOF {
return nil, io.EOF
}
if err != nil {
return nil, converter.FromGRPCError(err)
}
chunk, code, convErr := converter.ExecChunkFromEvent(ev)
if convErr != nil {
return nil, convErr
}
if chunk != nil {
return chunk, nil
}
// nil chunk with no error means exit event
s.exitCode = code
s.exited = true
s.hasExit = true
return nil, io.EOF
}
func (s *execStream) ExitCode() (int, error) {
if !s.exited {
for {
_, err := s.Next()
if err == io.EOF {
break
}
if err != nil {
return -1, err
}
}
}
if !s.hasExit {
return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"}
}
return s.exitCode, nil
}
func (s *execStream) Close() error {
if s.cancel != nil {
s.cancel()
}
return nil
}
// interactiveSession wraps a bidirectional streaming RPC into the InteractiveSession interface.
// A background goroutine owns the Recv loop and routes events to dataCh (for Read)
// and exitCh (for ExitCode), preventing concurrent Recv calls on the stream.
type interactiveSession struct {
stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent]
cancel context.CancelFunc
sendMu sync.Mutex
dataCh chan []byte
exitCh chan int
done chan struct{}
errOnce sync.Once
err error
buf []byte
exitMu sync.Mutex
exitCode int
hasExitCode bool
}
func newInteractiveSession(ctx context.Context, cancel context.CancelFunc, stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent]) *interactiveSession {
s := &interactiveSession{
stream: stream,
cancel: cancel,
dataCh: make(chan []byte, 64),
exitCh: make(chan int, 1),
done: make(chan struct{}),
}
go s.readLoop(ctx)
return s
}
func (s *interactiveSession) setErr(err error) {
s.errOnce.Do(func() { s.err = err })
}
func (s *interactiveSession) readLoop(ctx context.Context) {
defer close(s.dataCh)
defer close(s.done)
for {
ev, err := s.stream.Recv()
if err != nil {
if err != io.EOF {
s.setErr(converter.FromGRPCError(err))
}
return
}
chunk, code, convErr := converter.ExecChunkFromEvent(ev)
if convErr != nil {
s.setErr(convErr)
return
}
// nil chunk with no error means exit event
if chunk == nil {
select {
case s.exitCh <- code:
default:
}
return
}
select {
case s.dataCh <- chunk.Data:
case <-ctx.Done():
return
}
}
}
func (s *interactiveSession) Read(p []byte) (int, error) {
if len(s.buf) > 0 {
n := copy(p, s.buf)
s.buf = s.buf[n:]
return n, nil
}
data, ok := <-s.dataCh
if !ok {
if s.err != nil {
return 0, s.err
}
return 0, io.EOF
}
n := copy(p, data)
if n < len(data) {
s.buf = append(s.buf, data[n:]...)
}
return n, nil
}
func (s *interactiveSession) Write(p []byte) (int, error) {
s.sendMu.Lock()
defer s.sendMu.Unlock()
err := s.stream.Send(&pb.ExecSandboxInput{
Payload: &pb.ExecSandboxInput_Stdin{Stdin: p},
})
if err != nil {
return 0, converter.FromGRPCError(err)
}
return len(p), nil
}
func (s *interactiveSession) Resize(cols, rows uint32) error {
s.sendMu.Lock()
defer s.sendMu.Unlock()
err := s.stream.Send(&pb.ExecSandboxInput{
Payload: &pb.ExecSandboxInput_Resize{
Resize: &pb.ExecSandboxWindowResize{
Cols: cols,
Rows: rows,
},
},
})
if err != nil {
return converter.FromGRPCError(err)
}
return nil
}
func (s *interactiveSession) ExitCode() (int, error) {
s.exitMu.Lock()
if s.hasExitCode {
code := s.exitCode
s.exitMu.Unlock()
return code, nil
}
s.exitMu.Unlock()
select {
case code := <-s.exitCh:
s.exitMu.Lock()
s.exitCode = code
s.hasExitCode = true
s.exitMu.Unlock()
return code, nil
case <-s.done:
select {
case code := <-s.exitCh:
s.exitMu.Lock()
s.exitCode = code
s.hasExitCode = true
s.exitMu.Unlock()
return code, nil
default:
if s.err != nil {
return -1, s.err
}
return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"}
}
}
}
func (s *interactiveSession) Close() error {
s.cancel()
err := s.stream.CloseSend()
<-s.done
return err
}