mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-18 05:39:50 +08:00
* fix(pagination): document list contract and harden SDK pagers Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(pagination): bound pager token history Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(pagination): preflight token history limits Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(pagination): paginate sandbox providers Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(pagination): harden TypeScript pager Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(pagination): expose provider pagers in SDKs Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * docs(pagination): describe a uniform list contract Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(pagination): use stable provider cursors Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * docs(pagination): clarify mutation semantics Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(cli): expose sandbox provider pagination Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(proto): refresh pagination schema inventory Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> * fix(proto): refresh rebased schema inventory Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> Signed-off-by: Kris Hicks <khicks@nvidia.com> --------- Signed-off-by: Gaizka Menendez Hernandez <gmenende@redhat.com> Signed-off-by: Kris Hicks <khicks@nvidia.com>
765 lines
24 KiB
Go
765 lines
24 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(string, ...ListOptions) (*Pager[*Sandbox], error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) ListAll(context.Context, string, ...ListOptions) ([]*Sandbox, error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) Delete(context.Context, string, string, ...DeleteOptions) (*DeletionResult, 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(string, string, ...ListOptions) (*Pager[*Provider], error) {
|
|
panic("not implemented")
|
|
}
|
|
func (r *stubSandboxResolver) ListAllProviders(context.Context, string, string, ...ListOptions) ([]*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
|
|
interactiveWaitEOF bool
|
|
interactiveFinalErr error
|
|
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
|
|
waitEOF := s.interactiveWaitEOF
|
|
finalErr := s.interactiveFinalErr
|
|
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()
|
|
}
|
|
if waitEOF {
|
|
for {
|
|
_, recvErr := stream.Recv()
|
|
if recvErr == io.EOF {
|
|
break
|
|
}
|
|
if recvErr != nil {
|
|
return recvErr
|
|
}
|
|
}
|
|
}
|
|
|
|
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 finalErr
|
|
}
|
|
|
|
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, "test-sandbox", mock.lastExecRequest.GetSandbox())
|
|
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, "test-sandbox", startReq.GetSandbox())
|
|
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_CloseWriteDrainsOutput(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveWaitEOF = true
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("after EOF")}}},
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 7}}},
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
|
defer cancel()
|
|
session, err := client.Interactive(ctx, "default", "test-sandbox", []string{"cat"}, 0, 0)
|
|
require.NoError(t, err)
|
|
defer func() { _ = session.Close() }()
|
|
require.Implements(t, (*InteractiveSessionControl)(nil), session)
|
|
require.NoError(t, CloseInteractiveInput(session))
|
|
require.NoError(t, CloseInteractiveInput(session))
|
|
_, err = session.Write([]byte("late"))
|
|
require.ErrorIs(t, err, io.ErrClosedPipe)
|
|
require.ErrorIs(t, session.Resize(80, 24), io.ErrClosedPipe)
|
|
output, err := io.ReadAll(session)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "after EOF", string(output))
|
|
code, err := session.ExitCode()
|
|
require.NoError(t, err)
|
|
require.Equal(t, 7, code)
|
|
}
|
|
|
|
func TestExecInteractive_RetainsExitOnTerminalFailure(t *testing.T) {
|
|
for _, finalErr := range []error{status.Error(codes.Unavailable, "connection lost"), status.Error(codes.Canceled, "cancelled")} {
|
|
t.Run(status.Code(finalErr).String(), func(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 7}}},
|
|
}
|
|
mock.interactiveFinalErr = finalErr
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
|
defer cancel()
|
|
session, err := client.Interactive(ctx, "default", "test-sandbox", []string{"true"}, 0, 0)
|
|
require.NoError(t, err)
|
|
defer func() { _ = session.Close() }()
|
|
code, err := session.ExitCode()
|
|
require.Error(t, err)
|
|
require.Equal(t, 7, code)
|
|
var sdkErr *StatusError
|
|
require.ErrorAs(t, err, &sdkErr)
|
|
require.Equal(t, status.Code(finalErr), status.Code(sdkErr.Cause))
|
|
codeAgain, errAgain := session.ExitCode()
|
|
require.Equal(t, code, codeAgain)
|
|
require.Equal(t, err, errAgain)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestExecInteractive_RejectsEventsAfterExit(t *testing.T) {
|
|
for name, event := range map[string]*pb.ExecSandboxEvent{
|
|
"duplicate exit": {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}},
|
|
"late output": {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("late")}}},
|
|
} {
|
|
t.Run(name, func(t *testing.T) {
|
|
mock := newMockExecServer()
|
|
mock.interactiveEvents = []*pb.ExecSandboxEvent{
|
|
{Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, event,
|
|
}
|
|
client, cleanup := setupExecTest(t, mock)
|
|
defer cleanup()
|
|
ctx, cancel := context.WithTimeout(context.Background(), time.Second*5)
|
|
defer cancel()
|
|
session, err := client.Interactive(ctx, "default", "test-sandbox", []string{"true"}, 0, 0)
|
|
require.NoError(t, err)
|
|
defer func() { _ = session.Close() }()
|
|
code, err := session.ExitCode()
|
|
require.Equal(t, 0, code)
|
|
require.ErrorContains(t, err, "after exit")
|
|
})
|
|
}
|
|
}
|
|
|
|
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_UsesName(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, "my-sandbox", mock.lastExecRequest.GetSandbox())
|
|
}
|
|
|
|
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_UsesName(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, "my-sandbox", mock.lastExecRequest.GetSandbox())
|
|
}
|
|
|
|
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_UsesName(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, "my-sandbox", startReq.GetSandbox())
|
|
}
|
|
|
|
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
|
|
}
|