mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-17 05:31:16 +08:00
* feat(sdk/go): complete Go SDK with domain clients, auth, and hardening Add the Go SDK implementation covering all gateway RPC services with domain-typed clients, comprehensive OIDC authentication flows, fake test doubles, and proto converters. Domain clients: Sandbox, Provider, Exec, File, TCP, SSH, Policy, Profile, Health, Service, Config, Workspace, Inference, Refresh. Each client validates inputs, resolves sandboxes by name, and converts between domain types and proto at the boundary. Auth: OIDC authorization code (PKCE), device code (RFC 8628), and client credentials (RFC 6749 Section 4.4) flows with gateway config auto-resolution. Token refresh with singleflight deduplication and exponential backoff. Edge tunnel proxy for gRPC-over-WebSocket. Gateway: On-disk gateway discovery with user/system directory precedence, lazy token loading, and auth mode mapping. Testing: In-memory fake client with deep-copy isolation, watch broadcasting with filtering, and workspace-scoped object stores. Bufconn-based gRPC tests for all domain clients. Ref: #2044 Signed-off-by: Roland Huß <rhuss@redhat.com> * feat(sdk/go): add gateway client options for logger, timeout, and retry Add WithLogger, WithTimeout, and WithRetryPolicy options to the gateway package's NewClient function. These forward the corresponding Config fields (Logger, Timeout, RetryPolicy) through to the underlying SDK client, giving callers full control over observability, connection timeouts, and retry behavior when constructing clients from on-disk gateway configurations. Also adds RetryPolicy type to types package and extends Config with the three new fields. * fix(mise): sync sccache lock entry Signed-off-by: Drew Newberry <anewberry@nvidia.com> --------- Signed-off-by: Roland Huß <rhuss@redhat.com> Signed-off-by: Drew Newberry <anewberry@nvidia.com> Co-authored-by: Drew Newberry <anewberry@nvidia.com>
664 lines
20 KiB
Go
664 lines
20 KiB
Go
// 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"
|
|
"time"
|
|
|
|
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/status"
|
|
"google.golang.org/grpc/test/bufconn"
|
|
)
|
|
|
|
// stubSandboxResolver implements SandboxInterface for testing name-to-ID resolution.
|
|
// Get returns a Sandbox with ID = "sb-" + name. All other methods panic.
|
|
type stubSandboxResolver struct {
|
|
getErr error // if non-nil, Get returns this error
|
|
}
|
|
|
|
func (r *stubSandboxResolver) Get(_ context.Context, _, name string) (*Sandbox, error) {
|
|
if r.getErr != nil {
|
|
return nil, r.getErr
|
|
}
|
|
return &Sandbox{ID: "sb-" + name, Name: name}, nil
|
|
}
|
|
|
|
func (r *stubSandboxResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string, ...CreateOptions) (*Sandbox, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) Delete(context.Context, string, string) error {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) AttachProvider(context.Context, string, string, string, uint64) (*AttachProviderResult, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) DetachProvider(context.Context, string, string, string, uint64) (*DetachProviderResult, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) ListProviders(context.Context, string, string) ([]*Provider, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) WaitReady(context.Context, string, string, ...WaitOptions) (*Sandbox, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) Watch(context.Context, string, string, ...WatchOptions) (WatchInterface[*Sandbox], error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) GetLogs(context.Context, string, string, ...LogOption) (*LogResult, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) Stop(context.Context, string, string) (*Sandbox, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) Start(context.Context, string, string) (*Sandbox, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) WaitStopped(context.Context, string, string, ...WaitOptions) (*Sandbox, error) {
|
|
panic("not implemented")
|
|
}
|
|
|
|
type mockExecServer struct {
|
|
pb.UnimplementedOpenShellServer
|
|
mu sync.Mutex
|
|
execEvents []*pb.ExecSandboxEvent
|
|
execErr error
|
|
lastExecRequest *pb.ExecSandboxRequest
|
|
|
|
interactiveEvents []*pb.ExecSandboxEvent
|
|
interactiveErr error
|
|
interactiveWaitInput bool
|
|
interactiveBlock bool
|
|
receivedInputs []*pb.ExecSandboxInput
|
|
}
|
|
|
|
func newMockExecServer() *mockExecServer {
|
|
return &mockExecServer{}
|
|
}
|
|
|
|
func (s *mockExecServer) ExecSandbox(req *pb.ExecSandboxRequest, stream grpc.ServerStreamingServer[pb.ExecSandboxEvent]) error {
|
|
s.mu.Lock()
|
|
s.lastExecRequest = req
|
|
events := make([]*pb.ExecSandboxEvent, len(s.execEvents))
|
|
copy(events, s.execEvents)
|
|
execErr := s.execErr
|
|
s.mu.Unlock()
|
|
|
|
if execErr != nil {
|
|
return execErr
|
|
}
|
|
|
|
for _, ev := range events {
|
|
if err := stream.Send(ev); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *mockExecServer) ExecSandboxInteractive(stream grpc.BidiStreamingServer[pb.ExecSandboxInput, pb.ExecSandboxEvent]) error {
|
|
s.mu.Lock()
|
|
interactiveErr := s.interactiveErr
|
|
interactiveBlock := s.interactiveBlock
|
|
events := make([]*pb.ExecSandboxEvent, len(s.interactiveEvents))
|
|
copy(events, s.interactiveEvents)
|
|
s.mu.Unlock()
|
|
|
|
if interactiveErr != nil {
|
|
return interactiveErr
|
|
}
|
|
|
|
// Read the start message
|
|
startMsg, err := stream.Recv()
|
|
if err != nil {
|
|
return err
|
|
}
|
|
s.mu.Lock()
|
|
s.receivedInputs = append(s.receivedInputs, startMsg)
|
|
s.mu.Unlock()
|
|
if interactiveBlock {
|
|
<-stream.Context().Done()
|
|
return stream.Context().Err()
|
|
}
|
|
|
|
s.mu.Lock()
|
|
waitInput := s.interactiveWaitInput
|
|
s.mu.Unlock()
|
|
|
|
if waitInput {
|
|
msg, recvErr := stream.Recv()
|
|
if recvErr != nil {
|
|
return recvErr
|
|
}
|
|
s.mu.Lock()
|
|
s.receivedInputs = append(s.receivedInputs, msg)
|
|
s.mu.Unlock()
|
|
}
|
|
|
|
// Read subsequent messages until client closes, collecting them
|
|
go func() {
|
|
for {
|
|
msg, recvErr := stream.Recv()
|
|
if recvErr != nil {
|
|
return
|
|
}
|
|
s.mu.Lock()
|
|
s.receivedInputs = append(s.receivedInputs, msg)
|
|
s.mu.Unlock()
|
|
}
|
|
}()
|
|
|
|
// Send canned events
|
|
for _, ev := range events {
|
|
if err := stream.Send(ev); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func setupExecTest(t *testing.T, mock *mockExecServer) (*execClient, func()) {
|
|
t.Helper()
|
|
lis := bufconn.Listen(bufSize)
|
|
srv := grpc.NewServer()
|
|
pb.RegisterOpenShellServer(srv, mock)
|
|
go func() { _ = srv.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)
|
|
|
|
return newExecClient(conn, &stubSandboxResolver{}), func() {
|
|
_ = conn.Close()
|
|
srv.Stop()
|
|
}
|
|
}
|
|
|
|
// --- T043: Run and Stream tests ---
|
|
|
|
func TestExecRun(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("hello ")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("world\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("warn\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
result, err := client.Run(context.Background(), "default", "test-sandbox", []string{"echo", "hello", "world"})
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
assert.Equal(t, 0, result.ExitCode)
|
|
assert.Equal(t, []byte("hello world\n"), result.Stdout)
|
|
assert.Equal(t, []byte("warn\n"), result.Stderr)
|
|
}
|
|
|
|
func TestExecRun_WithOptions(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
opts := ExecOptions{
|
|
Env: map[string]string{"FOO": "bar"},
|
|
WorkDir: "/tmp",
|
|
}
|
|
result, err := client.Run(context.Background(), "default", "test-sandbox", []string{"ls"}, opts)
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
assert.Equal(t, 0, result.ExitCode)
|
|
|
|
mock.mu.Lock()
|
|
defer mock.mu.Unlock()
|
|
assert.Equal(t, "sb-test-sandbox", mock.lastExecRequest.GetSandboxId())
|
|
assert.Equal(t, []string{"ls"}, mock.lastExecRequest.GetCommand())
|
|
assert.Equal(t, "/tmp", mock.lastExecRequest.GetWorkdir())
|
|
assert.Equal(t, map[string]string{"FOO": "bar"}, mock.lastExecRequest.GetEnvironment())
|
|
}
|
|
|
|
func TestExecRun_NonZeroExit(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("fail\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
result, err := client.Run(context.Background(), "default", "test-sandbox", []string{"false"})
|
|
|
|
require.NoError(t, err)
|
|
require.NotNil(t, result)
|
|
assert.Equal(t, 1, result.ExitCode)
|
|
assert.Empty(t, result.Stdout)
|
|
assert.Equal(t, []byte("fail\n"), result.Stderr)
|
|
}
|
|
|
|
func TestExecRun_ServerError(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execErr = status.Error(codes.NotFound, "sandbox not found")
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
_, err := client.Run(context.Background(), "default", "missing-sandbox", []string{"ls"})
|
|
|
|
require.Error(t, err)
|
|
assert.True(t, IsNotFound(err))
|
|
}
|
|
|
|
func TestExecStream(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("err1\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 42}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
stream, err := client.Stream(context.Background(), "default", "test-sandbox", []string{"cat"})
|
|
require.NoError(t, err)
|
|
require.NotNil(t, stream)
|
|
defer func() { _ = stream.Close() }()
|
|
|
|
chunk1, err := stream.Next()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, StreamStdout, chunk1.Stream)
|
|
assert.Equal(t, []byte("line1\n"), chunk1.Data)
|
|
|
|
chunk2, err := stream.Next()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, StreamStderr, chunk2.Stream)
|
|
assert.Equal(t, []byte("err1\n"), chunk2.Data)
|
|
|
|
chunk3, err := stream.Next()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, StreamStdout, chunk3.Stream)
|
|
assert.Equal(t, []byte("line2\n"), chunk3.Data)
|
|
|
|
// Next call after exit should return io.EOF
|
|
_, err = stream.Next()
|
|
assert.ErrorIs(t, err, io.EOF)
|
|
|
|
exitCode, err := stream.ExitCode()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 42, exitCode)
|
|
}
|
|
|
|
func TestExecStream_ServerError(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execErr = status.Error(codes.Internal, "internal error")
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
stream, err := client.Stream(context.Background(), "default", "test-sandbox", []string{"ls"})
|
|
if err != nil {
|
|
var se *StatusError
|
|
require.ErrorAs(t, err, &se)
|
|
assert.Equal(t, ErrorInternal, se.Code)
|
|
return
|
|
}
|
|
_, err = stream.Next()
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestExecStream_EmptyOutput(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
stream, err := client.Stream(context.Background(), "default", "test-sandbox", []string{"true"})
|
|
require.NoError(t, err)
|
|
defer func() { _ = stream.Close() }()
|
|
|
|
_, err = stream.Next()
|
|
assert.ErrorIs(t, err, io.EOF)
|
|
|
|
exitCode, err := stream.ExitCode()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 0, exitCode)
|
|
}
|
|
|
|
// --- T044: Interactive session tests ---
|
|
|
|
func TestExecInteractive(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("output\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/bash"}, 80, 24)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, session)
|
|
defer func() { _ = session.Close() }()
|
|
|
|
// Read output
|
|
buf := make([]byte, 1024)
|
|
n, err := session.Read(buf)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, "$ ", string(buf[:n]))
|
|
|
|
// Verify start message was received
|
|
mock.mu.Lock()
|
|
require.GreaterOrEqual(t, len(mock.receivedInputs), 1)
|
|
startInput := mock.receivedInputs[0]
|
|
mock.mu.Unlock()
|
|
|
|
startReq := startInput.GetStart()
|
|
require.NotNil(t, startReq)
|
|
assert.Equal(t, "sb-test-sandbox", startReq.GetSandboxId())
|
|
assert.Equal(t, []string{"/bin/bash"}, startReq.GetCommand())
|
|
assert.True(t, startReq.GetTty())
|
|
assert.Equal(t, uint32(80), startReq.GetCols())
|
|
assert.Equal(t, uint32(24), startReq.GetRows())
|
|
}
|
|
|
|
func TestExecInteractive_CloseCancelsReceiveStream(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveBlock = true
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24)
|
|
require.NoError(t, err)
|
|
|
|
closed := make(chan error, 1)
|
|
go func() { closed <- session.Close() }()
|
|
|
|
select {
|
|
case err := <-closed:
|
|
require.NoError(t, err)
|
|
case <-time.After(time.Second):
|
|
t.Fatal("InteractiveSession.Close did not cancel the receive stream")
|
|
}
|
|
}
|
|
|
|
func TestExecInteractive_Write(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveWaitInput = true
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24)
|
|
require.NoError(t, err)
|
|
defer func() { _ = session.Close() }()
|
|
|
|
n, err := session.Write([]byte("ls\n"))
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 3, n)
|
|
}
|
|
|
|
func TestExecInteractive_Resize(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveWaitInput = true
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24)
|
|
require.NoError(t, err)
|
|
defer func() { _ = session.Close() }()
|
|
|
|
err = session.Resize(120, 40)
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestExecInteractive_ServerError(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveErr = status.Error(codes.PermissionDenied, "not allowed")
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24)
|
|
if err != nil {
|
|
assert.True(t, IsPermissionDenied(err), "expected permission denied, got: %v", err)
|
|
return
|
|
}
|
|
buf := make([]byte, 1024)
|
|
_, err = session.Read(buf)
|
|
require.Error(t, err)
|
|
}
|
|
|
|
func TestExecInteractive_ExitCode(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("done\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 130}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24)
|
|
require.NoError(t, err)
|
|
|
|
// Drain output
|
|
buf := make([]byte, 1024)
|
|
for {
|
|
_, readErr := session.Read(buf)
|
|
if readErr != nil {
|
|
break
|
|
}
|
|
}
|
|
|
|
exitCode, err := session.ExitCode()
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 130, exitCode)
|
|
|
|
_ = session.Close()
|
|
}
|
|
|
|
func TestExecInteractive_ConcurrentReadAndExitCode(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line3\n")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"sh"}, 80, 24)
|
|
require.NoError(t, err)
|
|
|
|
var wg sync.WaitGroup
|
|
var readData []byte
|
|
var readErr error
|
|
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
buf := make([]byte, 1024)
|
|
for {
|
|
n, err := session.Read(buf)
|
|
if err != nil {
|
|
readErr = err
|
|
return
|
|
}
|
|
readData = append(readData, buf[:n]...)
|
|
}
|
|
}()
|
|
|
|
exitCode, exitErr := session.ExitCode()
|
|
wg.Wait()
|
|
|
|
require.NoError(t, exitErr)
|
|
assert.Equal(t, 0, exitCode)
|
|
assert.Equal(t, io.EOF, readErr)
|
|
assert.Contains(t, string(readData), "line1\n")
|
|
}
|
|
|
|
// --- Name-to-ID resolution tests ---
|
|
|
|
func TestExecRun_ResolvesNameToID(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
_, err := client.Run(context.Background(), "default", "my-sandbox", []string{"echo", "hi"})
|
|
require.NoError(t, err)
|
|
|
|
mock.mu.Lock()
|
|
defer mock.mu.Unlock()
|
|
// Verify the proto request contains the resolved ID, not the name
|
|
assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId())
|
|
}
|
|
|
|
func TestExecRun_ResolutionError(t *testing.T) {
|
|
resolver := &stubSandboxResolver{
|
|
getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"},
|
|
}
|
|
client := newExecClient(stubConn(t), resolver)
|
|
|
|
_, err := client.Run(context.Background(), "default", "nonexistent", []string{"ls"})
|
|
require.Error(t, err)
|
|
assert.True(t, IsNotFound(err))
|
|
}
|
|
|
|
func TestExecStream_ResolvesNameToID(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.execEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
stream, err := client.Stream(context.Background(), "default", "my-sandbox", []string{"echo"})
|
|
require.NoError(t, err)
|
|
|
|
_, _ = stream.ExitCode()
|
|
_ = stream.Close()
|
|
|
|
mock.mu.Lock()
|
|
defer mock.mu.Unlock()
|
|
assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId())
|
|
}
|
|
|
|
func TestExecStream_ResolutionError(t *testing.T) {
|
|
resolver := &stubSandboxResolver{
|
|
getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"},
|
|
}
|
|
client := newExecClient(stubConn(t), resolver)
|
|
|
|
_, err := client.Stream(context.Background(), "default", "nonexistent", []string{"ls"})
|
|
require.Error(t, err)
|
|
assert.True(t, IsNotFound(err))
|
|
}
|
|
|
|
func TestExecInteractive_ResolvesNameToID(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
|
|
session, err := client.Interactive(context.Background(), "default", "my-sandbox", []string{"/bin/sh"}, 80, 24)
|
|
require.NoError(t, err)
|
|
|
|
_, _ = session.ExitCode()
|
|
_ = session.Close()
|
|
|
|
mock.mu.Lock()
|
|
defer mock.mu.Unlock()
|
|
require.NotEmpty(t, mock.receivedInputs)
|
|
startReq := mock.receivedInputs[0].GetStart()
|
|
require.NotNil(t, startReq)
|
|
assert.Equal(t, "sb-my-sandbox", startReq.GetSandboxId())
|
|
}
|
|
|
|
func TestExecInteractive_ResolutionError(t *testing.T) {
|
|
resolver := &stubSandboxResolver{
|
|
getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"},
|
|
}
|
|
client := newExecClient(stubConn(t), resolver)
|
|
|
|
_, err := client.Interactive(context.Background(), "default", "nonexistent", []string{"/bin/sh"}, 80, 24)
|
|
require.Error(t, err)
|
|
assert.True(t, IsNotFound(err))
|
|
}
|
|
|
|
func TestExecRun_EmptySandboxName(t *testing.T) {
|
|
client := newExecClient(stubConn(t), &stubSandboxResolver{})
|
|
_, err := client.Run(context.Background(), "default", "", []string{"ls"})
|
|
require.Error(t, err)
|
|
assert.True(t, IsInvalidArgument(err))
|
|
}
|
|
|
|
func TestExecStream_EmptySandboxName(t *testing.T) {
|
|
client := newExecClient(stubConn(t), &stubSandboxResolver{})
|
|
_, err := client.Stream(context.Background(), "default", "", []string{"ls"})
|
|
require.Error(t, err)
|
|
assert.True(t, IsInvalidArgument(err))
|
|
}
|
|
|
|
func TestExecInteractive_EmptySandboxName(t *testing.T) {
|
|
client := newExecClient(stubConn(t), &stubSandboxResolver{})
|
|
_, err := client.Interactive(context.Background(), "default", "", []string{"/bin/sh"}, 80, 24)
|
|
require.Error(t, err)
|
|
assert.True(t, IsInvalidArgument(err))
|
|
}
|
|
|
|
// stubConn creates a minimal gRPC connection for resolution-error tests
|
|
// where the RPC is never reached.
|
|
func stubConn(t *testing.T) *grpc.ClientConn {
|
|
t.Helper()
|
|
lis := bufconn.Listen(bufSize)
|
|
srv := grpc.NewServer()
|
|
pb.RegisterOpenShellServer(srv, newMockExecServer())
|
|
go func() { _ = srv.Serve(lis) }()
|
|
t.Cleanup(func() { srv.Stop() })
|
|
|
|
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() })
|
|
return conn
|
|
}
|