Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions sdk/go/docs/src/SUMMARY.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
- [Profiles](api/profiles.md)
- [Refresh](api/refresh.md)
- [Services](api/services.md)
- [Service discovery](api/service-discovery.md)
- [Files](api/files.md)
- [Health](api/health.md)
- [SSH](api/ssh.md)
Expand Down
63 changes: 63 additions & 0 deletions sdk/go/docs/src/api/service-discovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Service discovery

Accessor: `client.ServiceRoutes()` on the concrete SDK client. Consumers can
accept the narrow `ServiceDiscoveryInterface`; existing `ClientInterface` mocks
are unchanged. Discovery reads accepted supervisor installations for an explicit
workspace selection and enforces the gateway's authorization for that selection.

```go
snapshot, err := client.ServiceRoutes().Snapshot(ctx, "default")
if err != nil {
return err
}
for _, route := range snapshot.Routes {
fmt.Println(route.Hostname, route.Endpoint, route.ExpiresAt)
}

watch, err := client.ServiceRoutes().Watch(ctx, "default")
if err != nil {
return err
}
defer watch.Close()
for {
replacement, err := watch.Recv()
if errors.Is(err, io.EOF) {
break // reconnect and relist after normal bounded stream completion
}
if err != nil {
return err // an error is not an empty replacement
}
_ = replacement // atomically replace the complete cached workspace view
}
```

The SDK returns complete replacement snapshots without background prefetch or
implicit reconnect. A caller must expire cached entries at `ExpiresAt` even when
discovery is unavailable, reconnect/relist after stream termination, and clear
routes when authorization is revoked. `Close` cancels the RPC and is safe to call
concurrently with one receiving goroutine. Derive a cancelable watch context and
choose snapshot deadlines appropriate for the consumer.

Preserve `Hostname` as TLS SNI and HTTP authority when dialing `Endpoint`.
`InstallationVersion` stays stable across lease-only renewal; `Revision` changes
with expiry and is only an equality token. Installation is neither application
health nor proof of external ingress acceptance. Discovery-only certificate
identities can receive explicit selected-workspace or platform grants.

## Workspace selection

Existing `Snapshot(ctx, workspace)` and `Watch(ctx, workspace)` methods retain
single-workspace behavior. Use `SnapshotFor` and `WatchFor` for a named set or all
active workspaces:

```go
scope := openshell.ServiceRouteScope{Workspaces: []string{"team-a", "team-b"}}
snapshot, err := client.ServiceRoutes().SnapshotFor(ctx, scope)
watch, err := client.ServiceRoutes().WatchFor(ctx, openshell.ServiceRouteScope{AllWorkspaces: true})
```

Select either a nonempty set of up to 64 distinct workspace names or all
workspaces. Selection does not grant access: every workspace must be authorized.
All-workspaces queries require explicit platform authority or a discovery-only
certificate grant. Watches deliver complete replacements and recheck grants;
clients must independently expire routes and reconnect after bounded completion.
6 changes: 6 additions & 0 deletions sdk/go/openshell/v1/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ type Client struct {
templates SandboxTemplateInterface
providers ProviderInterface
services ServiceInterface
serviceRoutes ServiceDiscoveryInterface
exec ExecInterface
files FileInterface
health HealthInterface
Expand Down Expand Up @@ -105,6 +106,7 @@ func NewClient(cfg Config) (*Client, error) {
c.templates = newSandboxTemplateClient(conn)
c.providers = newProviderClient(conn)
c.services = newServiceClient(conn)
c.serviceRoutes = newServiceDiscoveryClient(conn)
c.exec = newExecClient(conn, c.sandboxes)
c.files = newFileClient(conn, c.sandboxes)
c.health = newHealthClient(conn)
Expand Down Expand Up @@ -138,6 +140,10 @@ func (c *Client) Providers() ProviderInterface { return c.providers }
// Services returns the service sub-client.
func (c *Client) Services() ServiceInterface { return c.services }

// ServiceRoutes returns the read-only workspace discovery client. This additive
// accessor has its own narrow consumer interface to preserve ClientInterface mocks.
func (c *Client) ServiceRoutes() ServiceDiscoveryInterface { return c.serviceRoutes }

// Exec returns the exec sub-client.
func (c *Client) Exec() ExecInterface { return c.exec }

Expand Down
42 changes: 42 additions & 0 deletions sdk/go/openshell/v1/internal/converter/service_discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package converter

import (
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
)

// ServiceRouteSnapshotFromProto validates and copies a full discovery view.
func ServiceRouteSnapshotFromProto(snapshot *pb.ServiceRouteSnapshot, workspace string) (*types.ServiceRouteSnapshot, error) {
return ServiceRouteSnapshotForScopeFromProto(snapshot, types.ServiceRouteScope{Workspaces: []string{workspace}})
}

// ServiceRouteSnapshotForScopeFromProto validates route containment and copies the complete view.
func ServiceRouteSnapshotForScopeFromProto(snapshot *pb.ServiceRouteSnapshot, scope types.ServiceRouteScope) (*types.ServiceRouteSnapshot, error) {
allowed := make(map[string]bool, len(scope.Workspaces))
for _, workspace := range scope.Workspaces {
allowed[workspace] = true
}
invalid := func() (*types.ServiceRouteSnapshot, error) {
return nil, &types.StatusError{Code: types.ErrorInternal, Message: "invalid service discovery snapshot"}
}
if snapshot == nil || snapshot.GetRevision() == "" || len(snapshot.GetRoutes()) > 4096 {
return invalid()
}
result := &types.ServiceRouteSnapshot{Revision: snapshot.GetRevision(), Routes: make([]types.DiscoveredServiceRoute, 0, len(snapshot.GetRoutes()))}
hostnames := make(map[string]bool, len(snapshot.GetRoutes()))
for _, route := range snapshot.GetRoutes() {
if route == nil || (route.GetWorkspace() == "" || (!scope.AllWorkspaces && !allowed[route.GetWorkspace()])) || route.GetHostname() == "" || route.GetInstallationVersion() == "" || route.GetEndpoint() == nil || route.GetEndpoint().GetHost() == "" || route.GetEndpoint().GetPort() == 0 || route.GetEndpoint().GetPort() > 65535 || route.GetExpiresAt() == nil || route.GetExpiresAt().CheckValid() != nil || hostnames[route.GetHostname()] {
return invalid()
}
hostnames[route.GetHostname()] = true
result.Routes = append(result.Routes, types.DiscoveredServiceRoute{
Workspace: route.GetWorkspace(), Sandbox: route.GetSandbox(), Name: route.GetName(), Hostname: route.GetHostname(),
Endpoint: types.ServiceRouteEndpoint{Host: route.GetEndpoint().GetHost(), Port: route.GetEndpoint().GetPort()},
InstallationVersion: route.GetInstallationVersion(), DeclarationRevision: route.GetDeclarationRevision(), ExpiresAt: route.GetExpiresAt().AsTime(),
})
}
return result, nil
}
44 changes: 44 additions & 0 deletions sdk/go/openshell/v1/internal/converter/service_discovery_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package converter

import (
"testing"
"time"

"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"github.com/stretchr/testify/require"
"google.golang.org/protobuf/types/known/timestamppb"
)

func TestDiscoverySnapshotCopiesAndRejectsIncompleteViews(t *testing.T) {
wire := &pb.ServiceRouteSnapshot{Revision: "revision", Routes: []*pb.DiscoveredServiceRoute{{
Workspace: "default", Sandbox: "demo", Hostname: "demo.example.com", InstallationVersion: "owner-1",
Endpoint: &pb.ServiceRouteEndpoint{Host: "10.0.0.1", Port: 9443}, ExpiresAt: timestamppb.New(time.Now()),
}}}
snapshot, err := ServiceRouteSnapshotFromProto(wire, "default")
require.NoError(t, err)
snapshot.Routes[0].Endpoint.Host = "changed"
require.Equal(t, "10.0.0.1", wire.Routes[0].Endpoint.Host)
wire.Routes[0].Hostname = "different.example.com"
require.Equal(t, "demo.example.com", snapshot.Routes[0].Hostname)
_, err = ServiceRouteSnapshotFromProto(wire, "other")
require.Error(t, err)
wire.Routes[0].ExpiresAt = nil
_, err = ServiceRouteSnapshotFromProto(wire, "default")
require.Error(t, err)
_, err = ServiceRouteSnapshotFromProto(nil, "default")
require.Error(t, err)
}

func TestDiscoveryConverterRejectsRoutesOutsideSelectedSet(t *testing.T) {
view := &pb.ServiceRouteSnapshot{Revision: "r", Routes: []*pb.DiscoveredServiceRoute{{Workspace: "outside", Hostname: "outside.example.com", InstallationVersion: "v", Endpoint: &pb.ServiceRouteEndpoint{Host: "10.0.0.1", Port: 9443}, ExpiresAt: timestamppb.Now()}}}
_, err := ServiceRouteSnapshotForScopeFromProto(view, types.ServiceRouteScope{Workspaces: []string{"team-a", "team-b"}})
require.Error(t, err)
result, err := ServiceRouteSnapshotForScopeFromProto(view, types.ServiceRouteScope{AllWorkspaces: true})
require.NoError(t, err)
result.Routes[0].Workspace = "changed"
require.Equal(t, "outside", view.Routes[0].Workspace)
}
150 changes: 150 additions & 0 deletions sdk/go/openshell/v1/service_discovery.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

package v1

import (
"context"
"io"
"slices"
"strings"
"sync"

"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter"
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
"google.golang.org/grpc"
)

// ServiceRouteSnapshot is a complete replacement view of workspace installations.
type ServiceRouteSnapshot = types.ServiceRouteSnapshot

// DiscoveredServiceRoute identifies an installed service and its current supervisor.
type DiscoveredServiceRoute = types.DiscoveredServiceRoute

// ServiceRouteEndpoint is the trusted supervisor TLS endpoint.
type ServiceRouteEndpoint = types.ServiceRouteEndpoint

// ServiceRouteScope selects a nonempty workspace set or all active workspaces.
type ServiceRouteScope = types.ServiceRouteScope

// ServiceRouteWatch delivers full replacements. Call Recv from one goroutine;
// Close is safe concurrently. EOF is normal bounded stream completion: reconnect
// and relist. Errors are not empty replacements. Expire cached routes independently.
type ServiceRouteWatch interface {
Recv() (*ServiceRouteSnapshot, error)
Close()
}

// ServiceDiscoveryInterface is a narrow interface for external discovery consumers.
// Selection never grants authority: the gateway must authorize every requested workspace.
type ServiceDiscoveryInterface interface {
Snapshot(ctx context.Context, workspace string) (*ServiceRouteSnapshot, error)
Watch(ctx context.Context, workspace string) (ServiceRouteWatch, error)
SnapshotFor(ctx context.Context, scope ServiceRouteScope) (*ServiceRouteSnapshot, error)
WatchFor(ctx context.Context, scope ServiceRouteScope) (ServiceRouteWatch, error)
}

type serviceDiscoveryClient struct{ client pb.OpenShellClient }

func newServiceDiscoveryClient(conn grpc.ClientConnInterface) *serviceDiscoveryClient {
return &serviceDiscoveryClient{client: pb.NewOpenShellClient(conn)}
}

func discoveryScope(scope ServiceRouteScope) (ServiceRouteScope, error) {
invalid := func() (ServiceRouteScope, error) {
return ServiceRouteScope{}, &StatusError{Code: ErrorInvalidArgument, Message: "choose up to 64 distinct workspaces or all workspaces"}
}
if scope.AllWorkspaces == (len(scope.Workspaces) > 0) || len(scope.Workspaces) > 64 {
return invalid()
}
scope.Workspaces = slices.Clone(scope.Workspaces)
slices.Sort(scope.Workspaces)
for i, name := range scope.Workspaces {
if name == "" || len(name) > 19 || strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") || (i > 0 && name == scope.Workspaces[i-1]) {
return invalid()
}
for _, c := range name {
if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
return invalid()
}
}
}
return scope, nil
}

func (s *serviceDiscoveryClient) Snapshot(ctx context.Context, workspace string) (*ServiceRouteSnapshot, error) {
return s.SnapshotFor(ctx, ServiceRouteScope{Workspaces: []string{workspace}})
}
func (s *serviceDiscoveryClient) Watch(ctx context.Context, workspace string) (ServiceRouteWatch, error) {
return s.WatchFor(ctx, ServiceRouteScope{Workspaces: []string{workspace}})
}

// SnapshotFor returns one complete replacement view for the selected discovery scope.
func (s *serviceDiscoveryClient) SnapshotFor(ctx context.Context, requested ServiceRouteScope) (*ServiceRouteSnapshot, error) {
scope, err := discoveryScope(requested)
if err != nil {
return nil, err
}
request := &pb.GetServiceRouteSnapshotRequest{}
if scope.AllWorkspaces {
request.WorkspaceScope = allWorkspacesScope()
} else if len(scope.Workspaces) == 1 {
request.WorkspaceScope = namedWorkspaceScope(scope.Workspaces[0])
} else {
request.Workspaces = scope.Workspaces
}
response, err := s.client.GetServiceRouteSnapshot(ctx, request)
if err != nil {
return nil, converter.FromGRPCError(err)
}
return converter.ServiceRouteSnapshotForScopeFromProto(response.GetSnapshot(), scope)
}

// WatchFor watches complete replacements, leaving reconnect policy to the caller.
func (s *serviceDiscoveryClient) WatchFor(ctx context.Context, requested ServiceRouteScope) (ServiceRouteWatch, error) {
scope, err := discoveryScope(requested)
if err != nil {
return nil, err
}
request := &pb.WatchServiceRoutesRequest{}
if scope.AllWorkspaces {
request.WorkspaceScope = allWorkspacesScope()
} else if len(scope.Workspaces) == 1 {
request.WorkspaceScope = namedWorkspaceScope(scope.Workspaces[0])
} else {
request.Workspaces = scope.Workspaces
}
ctx, cancel := context.WithCancel(ctx)
stream, err := s.client.WatchServiceRoutes(ctx, request)
if err != nil {
cancel()
return nil, converter.FromGRPCError(err)
}
return &serviceRouteWatch{stream: stream, cancel: cancel, scope: scope}, nil
}

type serviceRouteWatch struct {
stream grpc.ServerStreamingClient[pb.WatchServiceRoutesResponse]
cancel context.CancelFunc
scope ServiceRouteScope
once sync.Once
}

func (w *serviceRouteWatch) Close() { w.once.Do(w.cancel) }

func (w *serviceRouteWatch) Recv() (*ServiceRouteSnapshot, error) {
response, err := w.stream.Recv()
if err != nil {
w.Close()
if err == io.EOF {
return nil, io.EOF
}
return nil, converter.FromGRPCError(err)
}
result, err := converter.ServiceRouteSnapshotForScopeFromProto(response.GetSnapshot(), w.scope)
if err != nil {
w.Close()
}
return result, err
}
Loading
Loading