mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-10-19 05:50:41 +08:00
* 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>
162 lines
4.0 KiB
Go
162 lines
4.0 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, workspace, sandboxName string) (*SSHSession, error) {
|
|
resp, err := s.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{
|
|
Sandbox: sandboxName,
|
|
WorkspaceScope: namedWorkspaceScope(workspace),
|
|
})
|
|
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),
|
|
}
|
|
}
|
|
if _, err := s.sandboxes.Get(ctx, workspace, sandboxName); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var cfg tunnelConfig
|
|
options.Apply(&cfg, opts)
|
|
|
|
session, err := s.CreateSession(ctx, workspace, sandboxName)
|
|
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{
|
|
Sandbox: sandboxName,
|
|
Workspace: workspace,
|
|
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
|
|
}
|