mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-24 06:34:33 +08:00
* 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>
162 lines
3.9 KiB
Go
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
|
|
}
|