Files
OpenShell/sdk/go/openshell/v1/ssh_client.go
Mrunal Patel c502be9fd7 feat(api): return typed deletion outcomes with explicit missing-target semantics (#3317)
* feat(api): return typed deletion outcomes with explicit missing-target semantics

Implement phase 2 of #3051 across the public gateway API and first-party SDKs. Preserve asynchronous sandbox deletion and observed resource identities, reserve legacy wire fields, and document the coordinated migration.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* fix(sdk): bind deletion waits to sandbox identity

Track accepted deletions by original sandbox identity in Rust and TypeScript. Preserve name-only waits and cover replacement races and lookup failures.

Merge the phase-one cleanup fix and adapt its interceptor regressions to typed deletion outcomes.

Refs #3051.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

* test(e2e): accept asynchronous stopped sandbox deletion

Allow either completed or accepted deletion output, then continue polling for actual sandbox absence. This preserves the lifecycle assertion under the phase-two deletion contract.

Refs #3051.

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>

---------

Signed-off-by: Mrunal Patel <mrunalp@gmail.com>
2026-09-16 22:11:19 +00:00

162 lines
3.9 KiB
Go

// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0
package v1
import (
"context"
"fmt"
"io"
"sync"
"time"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/options"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"google.golang.org/grpc"
)
const sshCleanupTimeout = 5 * time.Second
type sshClient struct {
client pb.OpenShellClient
sandboxes SandboxInterface
}
func newSSHClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *sshClient {
return &sshClient{
client: pb.NewOpenShellClient(conn),
sandboxes: sandboxes,
}
}
func (s *sshClient) CreateSession(ctx context.Context, _, sandboxID string) (*SSHSession, error) {
resp, err := s.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{
SandboxId: sandboxID,
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
return converter.SSHSessionFromProto(resp), nil
}
func (s *sshClient) RevokeSession(ctx context.Context, _, token string, opts ...DeleteOptions) (*DeletionResult, error) {
resp, err := s.client.RevokeSshSession(ctx, &pb.RevokeSshSessionRequest{
AllowMissing: allowMissing(opts),
Token: token,
})
if err != nil {
return nil, converter.FromGRPCError(err)
}
return &DeletionResult{Outcome: DeletionOutcome(resp.GetOutcome())}, nil
}
func (s *sshClient) Tunnel(ctx context.Context, workspace, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error) {
if sandboxName == "" {
return nil, &StatusError{
Code: ErrorInvalidArgument,
Message: "sandbox name must not be empty",
}
}
if port == 0 || port > 65535 {
return nil, &StatusError{
Code: ErrorInvalidArgument,
Message: fmt.Sprintf("port must be in range 1-65535, got %d", port),
}
}
var cfg tunnelConfig
options.Apply(&cfg, opts)
sandbox, err := s.sandboxes.Get(ctx, workspace, sandboxName)
if err != nil {
return nil, err
}
session, err := s.CreateSession(ctx, workspace, sandbox.ID)
if err != nil {
return nil, err
}
revokeSession := true
defer func() {
if revokeSession {
s.revokeSessionForCleanup(workspace, session.Token)
}
}()
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{
SandboxId: sandbox.ID,
ServiceId: cfg.serviceID,
AuthorizationToken: session.Token,
Target: &pb.TcpForwardInit_Ssh{
Ssh: &pb.SshRelayTarget{},
},
},
},
}
if err := stream.Send(initFrame); err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
conn := &tcpForwardConn{
stream: stream,
streamCtx: streamCtx,
cancel: cancel,
dataCh: make(chan []byte, 64),
done: make(chan struct{}),
}
go conn.readLoop()
revokeSession = false
t := &sshTunnel{
tcpForwardConn: conn,
revokeFunc: func() {
s.revokeSessionForCleanup(workspace, session.Token)
},
}
// Auto-revoke the SSH session when the parent context is cancelled.
// The done channel closes after readLoop exits (stream fully drained),
// so Close() won't race with an active stream.
go func() {
<-conn.done
_ = t.Close()
}()
return t, nil
}
func (s *sshClient) revokeSessionForCleanup(workspace, token string) {
ctx, cancel := context.WithTimeout(context.Background(), sshCleanupTimeout)
defer cancel()
_, _ = s.RevokeSession(ctx, workspace, token, DeleteOptions{AllowMissing: true})
}
type sshTunnel struct {
*tcpForwardConn
revokeFunc func()
closeOnce sync.Once
closeErr error
}
func (t *sshTunnel) Close() error {
t.closeOnce.Do(func() {
t.closeErr = t.tcpForwardConn.Close()
t.revokeFunc()
})
return t.closeErr
}