Files
OpenShell/sdk/go/openshell/v1/ssh_client.go
Drew Newberry d91b1999a0 feat(api)!: use sandbox names as canonical RPC references (#3272)
* 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>
2026-09-18 11:39:55 -07:00

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
}