Skip to content

Commit e01a394

Browse files
committed
feat(sdk/go): add service discovery snapshots, watches, and scopes
Expose a narrow service discovery client with protobuf-independent domain types, validated deep-copying conversions, and explicit stream lifecycle. Preserve single-workspace calls and add SnapshotFor/WatchFor for finite workspace sets or platform scope, with scope containment checks. Closes #4955 Refs #4668 Signed-off-by: Taylor Mutch <taylormutch@gmail.com>
1 parent 7624c50 commit e01a394

8 files changed

Lines changed: 536 additions & 0 deletions

File tree

‎sdk/go/docs/src/SUMMARY.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
- [Profiles](api/profiles.md)
2222
- [Refresh](api/refresh.md)
2323
- [Services](api/services.md)
24+
- [Service discovery](api/service-discovery.md)
2425
- [Files](api/files.md)
2526
- [Health](api/health.md)
2627
- [SSH](api/ssh.md)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
# Service discovery
2+
3+
Accessor: `client.ServiceRoutes()` on the concrete SDK client. Consumers can
4+
accept the narrow `ServiceDiscoveryInterface`; existing `ClientInterface` mocks
5+
are unchanged. Discovery reads accepted supervisor installations for an explicit
6+
workspace selection and enforces the gateway's authorization for that selection.
7+
8+
```go
9+
snapshot, err := client.ServiceRoutes().Snapshot(ctx, "default")
10+
if err != nil {
11+
return err
12+
}
13+
for _, route := range snapshot.Routes {
14+
fmt.Println(route.Hostname, route.Endpoint, route.ExpiresAt)
15+
}
16+
17+
watch, err := client.ServiceRoutes().Watch(ctx, "default")
18+
if err != nil {
19+
return err
20+
}
21+
defer watch.Close()
22+
for {
23+
replacement, err := watch.Recv()
24+
if errors.Is(err, io.EOF) {
25+
break // reconnect and relist after normal bounded stream completion
26+
}
27+
if err != nil {
28+
return err // an error is not an empty replacement
29+
}
30+
_ = replacement // atomically replace the complete cached workspace view
31+
}
32+
```
33+
34+
The SDK returns complete replacement snapshots without background prefetch or
35+
implicit reconnect. A caller must expire cached entries at `ExpiresAt` even when
36+
discovery is unavailable, reconnect/relist after stream termination, and clear
37+
routes when authorization is revoked. `Close` cancels the RPC and is safe to call
38+
concurrently with one receiving goroutine. Derive a cancelable watch context and
39+
choose snapshot deadlines appropriate for the consumer.
40+
41+
Preserve `Hostname` as TLS SNI and HTTP authority when dialing `Endpoint`.
42+
`InstallationVersion` stays stable across lease-only renewal; `Revision` changes
43+
with expiry and is only an equality token. Installation is neither application
44+
health nor proof of external ingress acceptance. Discovery-only certificate
45+
identities can receive explicit selected-workspace or platform grants.
46+
47+
## Workspace selection
48+
49+
Existing `Snapshot(ctx, workspace)` and `Watch(ctx, workspace)` methods retain
50+
single-workspace behavior. Use `SnapshotFor` and `WatchFor` for a named set or all
51+
active workspaces:
52+
53+
```go
54+
scope := openshell.ServiceRouteScope{Workspaces: []string{"team-a", "team-b"}}
55+
snapshot, err := client.ServiceRoutes().SnapshotFor(ctx, scope)
56+
watch, err := client.ServiceRoutes().WatchFor(ctx, openshell.ServiceRouteScope{AllWorkspaces: true})
57+
```
58+
59+
Select either a nonempty set of up to 64 distinct workspace names or all
60+
workspaces. Selection does not grant access: every workspace must be authorized.
61+
All-workspaces queries require explicit platform authority or a discovery-only
62+
certificate grant. Watches deliver complete replacements and recheck grants;
63+
clients must independently expire routes and reconnect after bounded completion.

‎sdk/go/openshell/v1/client.go‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,7 @@ type Client struct {
5858
templates SandboxTemplateInterface
5959
providers ProviderInterface
6060
services ServiceInterface
61+
serviceRoutes ServiceDiscoveryInterface
6162
exec ExecInterface
6263
files FileInterface
6364
health HealthInterface
@@ -105,6 +106,7 @@ func NewClient(cfg Config) (*Client, error) {
105106
c.templates = newSandboxTemplateClient(conn)
106107
c.providers = newProviderClient(conn)
107108
c.services = newServiceClient(conn)
109+
c.serviceRoutes = newServiceDiscoveryClient(conn)
108110
c.exec = newExecClient(conn, c.sandboxes)
109111
c.files = newFileClient(conn, c.sandboxes)
110112
c.health = newHealthClient(conn)
@@ -138,6 +140,10 @@ func (c *Client) Providers() ProviderInterface { return c.providers }
138140
// Services returns the service sub-client.
139141
func (c *Client) Services() ServiceInterface { return c.services }
140142

143+
// ServiceRoutes returns the read-only workspace discovery client. This additive
144+
// accessor has its own narrow consumer interface to preserve ClientInterface mocks.
145+
func (c *Client) ServiceRoutes() ServiceDiscoveryInterface { return c.serviceRoutes }
146+
141147
// Exec returns the exec sub-client.
142148
func (c *Client) Exec() ExecInterface { return c.exec }
143149

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package converter
5+
6+
import (
7+
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
8+
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
9+
)
10+
11+
// ServiceRouteSnapshotFromProto validates and copies a full discovery view.
12+
func ServiceRouteSnapshotFromProto(snapshot *pb.ServiceRouteSnapshot, workspace string) (*types.ServiceRouteSnapshot, error) {
13+
return ServiceRouteSnapshotForScopeFromProto(snapshot, types.ServiceRouteScope{Workspaces: []string{workspace}})
14+
}
15+
16+
// ServiceRouteSnapshotForScopeFromProto validates route containment and copies the complete view.
17+
func ServiceRouteSnapshotForScopeFromProto(snapshot *pb.ServiceRouteSnapshot, scope types.ServiceRouteScope) (*types.ServiceRouteSnapshot, error) {
18+
allowed := make(map[string]bool, len(scope.Workspaces))
19+
for _, workspace := range scope.Workspaces {
20+
allowed[workspace] = true
21+
}
22+
invalid := func() (*types.ServiceRouteSnapshot, error) {
23+
return nil, &types.StatusError{Code: types.ErrorInternal, Message: "invalid service discovery snapshot"}
24+
}
25+
if snapshot == nil || snapshot.GetRevision() == "" || len(snapshot.GetRoutes()) > 4096 {
26+
return invalid()
27+
}
28+
result := &types.ServiceRouteSnapshot{Revision: snapshot.GetRevision(), Routes: make([]types.DiscoveredServiceRoute, 0, len(snapshot.GetRoutes()))}
29+
hostnames := make(map[string]bool, len(snapshot.GetRoutes()))
30+
for _, route := range snapshot.GetRoutes() {
31+
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()] {
32+
return invalid()
33+
}
34+
hostnames[route.GetHostname()] = true
35+
result.Routes = append(result.Routes, types.DiscoveredServiceRoute{
36+
Workspace: route.GetWorkspace(), Sandbox: route.GetSandbox(), Name: route.GetName(), Hostname: route.GetHostname(),
37+
Endpoint: types.ServiceRouteEndpoint{Host: route.GetEndpoint().GetHost(), Port: route.GetEndpoint().GetPort()},
38+
InstallationVersion: route.GetInstallationVersion(), DeclarationRevision: route.GetDeclarationRevision(), ExpiresAt: route.GetExpiresAt().AsTime(),
39+
})
40+
}
41+
return result, nil
42+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package converter
5+
6+
import (
7+
"testing"
8+
"time"
9+
10+
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
11+
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
12+
"github.com/stretchr/testify/require"
13+
"google.golang.org/protobuf/types/known/timestamppb"
14+
)
15+
16+
func TestDiscoverySnapshotCopiesAndRejectsIncompleteViews(t *testing.T) {
17+
wire := &pb.ServiceRouteSnapshot{Revision: "revision", Routes: []*pb.DiscoveredServiceRoute{{
18+
Workspace: "default", Sandbox: "demo", Hostname: "demo.example.com", InstallationVersion: "owner-1",
19+
Endpoint: &pb.ServiceRouteEndpoint{Host: "10.0.0.1", Port: 9443}, ExpiresAt: timestamppb.New(time.Now()),
20+
}}}
21+
snapshot, err := ServiceRouteSnapshotFromProto(wire, "default")
22+
require.NoError(t, err)
23+
snapshot.Routes[0].Endpoint.Host = "changed"
24+
require.Equal(t, "10.0.0.1", wire.Routes[0].Endpoint.Host)
25+
wire.Routes[0].Hostname = "different.example.com"
26+
require.Equal(t, "demo.example.com", snapshot.Routes[0].Hostname)
27+
_, err = ServiceRouteSnapshotFromProto(wire, "other")
28+
require.Error(t, err)
29+
wire.Routes[0].ExpiresAt = nil
30+
_, err = ServiceRouteSnapshotFromProto(wire, "default")
31+
require.Error(t, err)
32+
_, err = ServiceRouteSnapshotFromProto(nil, "default")
33+
require.Error(t, err)
34+
}
35+
36+
func TestDiscoveryConverterRejectsRoutesOutsideSelectedSet(t *testing.T) {
37+
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()}}}
38+
_, err := ServiceRouteSnapshotForScopeFromProto(view, types.ServiceRouteScope{Workspaces: []string{"team-a", "team-b"}})
39+
require.Error(t, err)
40+
result, err := ServiceRouteSnapshotForScopeFromProto(view, types.ServiceRouteScope{AllWorkspaces: true})
41+
require.NoError(t, err)
42+
result.Routes[0].Workspace = "changed"
43+
require.Equal(t, "outside", view.Routes[0].Workspace)
44+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
package v1
5+
6+
import (
7+
"context"
8+
"io"
9+
"slices"
10+
"strings"
11+
"sync"
12+
13+
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter"
14+
"github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types"
15+
pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1"
16+
"google.golang.org/grpc"
17+
)
18+
19+
// ServiceRouteSnapshot is a complete replacement view of workspace installations.
20+
type ServiceRouteSnapshot = types.ServiceRouteSnapshot
21+
22+
// DiscoveredServiceRoute identifies an installed service and its current supervisor.
23+
type DiscoveredServiceRoute = types.DiscoveredServiceRoute
24+
25+
// ServiceRouteEndpoint is the trusted supervisor TLS endpoint.
26+
type ServiceRouteEndpoint = types.ServiceRouteEndpoint
27+
28+
// ServiceRouteScope selects a nonempty workspace set or all active workspaces.
29+
type ServiceRouteScope = types.ServiceRouteScope
30+
31+
// ServiceRouteWatch delivers full replacements. Call Recv from one goroutine;
32+
// Close is safe concurrently. EOF is normal bounded stream completion: reconnect
33+
// and relist. Errors are not empty replacements. Expire cached routes independently.
34+
type ServiceRouteWatch interface {
35+
Recv() (*ServiceRouteSnapshot, error)
36+
Close()
37+
}
38+
39+
// ServiceDiscoveryInterface is a narrow interface for external discovery consumers.
40+
// Selection never grants authority: the gateway must authorize every requested workspace.
41+
type ServiceDiscoveryInterface interface {
42+
Snapshot(ctx context.Context, workspace string) (*ServiceRouteSnapshot, error)
43+
Watch(ctx context.Context, workspace string) (ServiceRouteWatch, error)
44+
SnapshotFor(ctx context.Context, scope ServiceRouteScope) (*ServiceRouteSnapshot, error)
45+
WatchFor(ctx context.Context, scope ServiceRouteScope) (ServiceRouteWatch, error)
46+
}
47+
48+
type serviceDiscoveryClient struct{ client pb.OpenShellClient }
49+
50+
func newServiceDiscoveryClient(conn grpc.ClientConnInterface) *serviceDiscoveryClient {
51+
return &serviceDiscoveryClient{client: pb.NewOpenShellClient(conn)}
52+
}
53+
54+
func discoveryScope(scope ServiceRouteScope) (ServiceRouteScope, error) {
55+
invalid := func() (ServiceRouteScope, error) {
56+
return ServiceRouteScope{}, &StatusError{Code: ErrorInvalidArgument, Message: "choose up to 64 distinct workspaces or all workspaces"}
57+
}
58+
if scope.AllWorkspaces == (len(scope.Workspaces) > 0) || len(scope.Workspaces) > 64 {
59+
return invalid()
60+
}
61+
scope.Workspaces = slices.Clone(scope.Workspaces)
62+
slices.Sort(scope.Workspaces)
63+
for i, name := range scope.Workspaces {
64+
if name == "" || len(name) > 19 || strings.HasPrefix(name, "-") || strings.HasSuffix(name, "-") || (i > 0 && name == scope.Workspaces[i-1]) {
65+
return invalid()
66+
}
67+
for _, c := range name {
68+
if (c < 'a' || c > 'z') && (c < '0' || c > '9') && c != '-' {
69+
return invalid()
70+
}
71+
}
72+
}
73+
return scope, nil
74+
}
75+
76+
func (s *serviceDiscoveryClient) Snapshot(ctx context.Context, workspace string) (*ServiceRouteSnapshot, error) {
77+
return s.SnapshotFor(ctx, ServiceRouteScope{Workspaces: []string{workspace}})
78+
}
79+
func (s *serviceDiscoveryClient) Watch(ctx context.Context, workspace string) (ServiceRouteWatch, error) {
80+
return s.WatchFor(ctx, ServiceRouteScope{Workspaces: []string{workspace}})
81+
}
82+
83+
// SnapshotFor returns one complete replacement view for the selected discovery scope.
84+
func (s *serviceDiscoveryClient) SnapshotFor(ctx context.Context, requested ServiceRouteScope) (*ServiceRouteSnapshot, error) {
85+
scope, err := discoveryScope(requested)
86+
if err != nil {
87+
return nil, err
88+
}
89+
request := &pb.GetServiceRouteSnapshotRequest{}
90+
if scope.AllWorkspaces {
91+
request.WorkspaceScope = allWorkspacesScope()
92+
} else if len(scope.Workspaces) == 1 {
93+
request.WorkspaceScope = namedWorkspaceScope(scope.Workspaces[0])
94+
} else {
95+
request.Workspaces = scope.Workspaces
96+
}
97+
response, err := s.client.GetServiceRouteSnapshot(ctx, request)
98+
if err != nil {
99+
return nil, converter.FromGRPCError(err)
100+
}
101+
return converter.ServiceRouteSnapshotForScopeFromProto(response.GetSnapshot(), scope)
102+
}
103+
104+
// WatchFor watches complete replacements, leaving reconnect policy to the caller.
105+
func (s *serviceDiscoveryClient) WatchFor(ctx context.Context, requested ServiceRouteScope) (ServiceRouteWatch, error) {
106+
scope, err := discoveryScope(requested)
107+
if err != nil {
108+
return nil, err
109+
}
110+
request := &pb.WatchServiceRoutesRequest{}
111+
if scope.AllWorkspaces {
112+
request.WorkspaceScope = allWorkspacesScope()
113+
} else if len(scope.Workspaces) == 1 {
114+
request.WorkspaceScope = namedWorkspaceScope(scope.Workspaces[0])
115+
} else {
116+
request.Workspaces = scope.Workspaces
117+
}
118+
ctx, cancel := context.WithCancel(ctx)
119+
stream, err := s.client.WatchServiceRoutes(ctx, request)
120+
if err != nil {
121+
cancel()
122+
return nil, converter.FromGRPCError(err)
123+
}
124+
return &serviceRouteWatch{stream: stream, cancel: cancel, scope: scope}, nil
125+
}
126+
127+
type serviceRouteWatch struct {
128+
stream grpc.ServerStreamingClient[pb.WatchServiceRoutesResponse]
129+
cancel context.CancelFunc
130+
scope ServiceRouteScope
131+
once sync.Once
132+
}
133+
134+
func (w *serviceRouteWatch) Close() { w.once.Do(w.cancel) }
135+
136+
func (w *serviceRouteWatch) Recv() (*ServiceRouteSnapshot, error) {
137+
response, err := w.stream.Recv()
138+
if err != nil {
139+
w.Close()
140+
if err == io.EOF {
141+
return nil, io.EOF
142+
}
143+
return nil, converter.FromGRPCError(err)
144+
}
145+
result, err := converter.ServiceRouteSnapshotForScopeFromProto(response.GetSnapshot(), w.scope)
146+
if err != nil {
147+
w.Close()
148+
}
149+
return result, err
150+
}

0 commit comments

Comments
 (0)