From b83f71c3c4e9f9eef9a842826f8fbe90edfaee51 Mon Sep 17 00:00:00 2001 From: Taylor Mutch Date: Mon, 26 Oct 2026 21:07:10 -0700 Subject: [PATCH] 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 --- sdk/go/docs/src/SUMMARY.md | 1 + sdk/go/docs/src/api/service-discovery.md | 63 ++++++ sdk/go/openshell/v1/client.go | 6 + .../internal/converter/service_discovery.go | 42 ++++ .../converter/service_discovery_test.go | 44 ++++ sdk/go/openshell/v1/service_discovery.go | 150 ++++++++++++++ sdk/go/openshell/v1/service_discovery_test.go | 191 ++++++++++++++++++ .../openshell/v1/types/service_discovery.go | 39 ++++ 8 files changed, 536 insertions(+) create mode 100644 sdk/go/docs/src/api/service-discovery.md create mode 100644 sdk/go/openshell/v1/internal/converter/service_discovery.go create mode 100644 sdk/go/openshell/v1/internal/converter/service_discovery_test.go create mode 100644 sdk/go/openshell/v1/service_discovery.go create mode 100644 sdk/go/openshell/v1/service_discovery_test.go create mode 100644 sdk/go/openshell/v1/types/service_discovery.go diff --git a/sdk/go/docs/src/SUMMARY.md b/sdk/go/docs/src/SUMMARY.md index bf500f68eb..9f6f1ae4f0 100644 --- a/sdk/go/docs/src/SUMMARY.md +++ b/sdk/go/docs/src/SUMMARY.md @@ -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) diff --git a/sdk/go/docs/src/api/service-discovery.md b/sdk/go/docs/src/api/service-discovery.md new file mode 100644 index 0000000000..c9c86c3c3b --- /dev/null +++ b/sdk/go/docs/src/api/service-discovery.md @@ -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. diff --git a/sdk/go/openshell/v1/client.go b/sdk/go/openshell/v1/client.go index 30e564f6c4..dc8d9343ae 100644 --- a/sdk/go/openshell/v1/client.go +++ b/sdk/go/openshell/v1/client.go @@ -58,6 +58,7 @@ type Client struct { templates SandboxTemplateInterface providers ProviderInterface services ServiceInterface + serviceRoutes ServiceDiscoveryInterface exec ExecInterface files FileInterface health HealthInterface @@ -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) @@ -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 } diff --git a/sdk/go/openshell/v1/internal/converter/service_discovery.go b/sdk/go/openshell/v1/internal/converter/service_discovery.go new file mode 100644 index 0000000000..acd163c292 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/service_discovery.go @@ -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 +} diff --git a/sdk/go/openshell/v1/internal/converter/service_discovery_test.go b/sdk/go/openshell/v1/internal/converter/service_discovery_test.go new file mode 100644 index 0000000000..fe256eed1c --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/service_discovery_test.go @@ -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) +} diff --git a/sdk/go/openshell/v1/service_discovery.go b/sdk/go/openshell/v1/service_discovery.go new file mode 100644 index 0000000000..65ee22c6f1 --- /dev/null +++ b/sdk/go/openshell/v1/service_discovery.go @@ -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 +} diff --git a/sdk/go/openshell/v1/service_discovery_test.go b/sdk/go/openshell/v1/service_discovery_test.go new file mode 100644 index 0000000000..5540532e90 --- /dev/null +++ b/sdk/go/openshell/v1/service_discovery_test.go @@ -0,0 +1,191 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "net" + "testing" + "time" + + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/require" + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + "google.golang.org/protobuf/types/known/timestamppb" +) + +type discoveryServer struct { + pb.UnimplementedOpenShellServer + views []*pb.ServiceRouteSnapshot + terminal error + block bool + canceled chan struct{} + scopeCheck func(*pb.GetServiceRouteSnapshotRequest) error +} + +func discoveryView(revision string, expires time.Time) *pb.ServiceRouteSnapshot { + return &pb.ServiceRouteSnapshot{Revision: revision, Routes: []*pb.DiscoveredServiceRoute{{ + Workspace: "default", Sandbox: "demo", Name: "web", Hostname: "demo.example.com", + Endpoint: &pb.ServiceRouteEndpoint{Host: "10.0.0.1", Port: 9443}, + InstallationVersion: "owner-1", DeclarationRevision: 7, ExpiresAt: timestamppb.New(expires), + }}} +} + +func (s *discoveryServer) GetServiceRouteSnapshot(_ context.Context, req *pb.GetServiceRouteSnapshotRequest) (*pb.GetServiceRouteSnapshotResponse, error) { + if s.scopeCheck != nil { + if err := s.scopeCheck(req); err != nil { + return nil, err + } + } else if req.GetWorkspaceScope().GetWorkspace() != "default" { + return nil, status.Error(codes.PermissionDenied, "workspace denied") + } + return &pb.GetServiceRouteSnapshotResponse{Snapshot: s.views[0]}, nil +} +func (s *discoveryServer) WatchServiceRoutes(req *pb.WatchServiceRoutesRequest, stream grpc.ServerStreamingServer[pb.WatchServiceRoutesResponse]) error { + if s.scopeCheck != nil { + if err := s.scopeCheck(&pb.GetServiceRouteSnapshotRequest{WorkspaceScope: req.WorkspaceScope, Workspaces: req.Workspaces}); err != nil { + return err + } + } else if _, ok := req.GetWorkspaceScope().GetSelection().(*dm.WorkspaceSelector_Workspace); !ok { + return status.Error(codes.InvalidArgument, "explicit workspace required") + } + for _, view := range s.views { + if err := stream.Send(&pb.WatchServiceRoutesResponse{Snapshot: view}); err != nil { + return err + } + } + if s.block { + <-stream.Context().Done() + close(s.canceled) + return status.FromContextError(stream.Context().Err()).Err() + } + return s.terminal +} + +func discoveryClientForTest(t *testing.T, mock *discoveryServer) *serviceDiscoveryClient { + t.Helper() + listener := bufconn.Listen(bufSize) + server := grpc.NewServer() + pb.RegisterOpenShellServer(server, mock) + go func() { _ = server.Serve(listener) }() + conn, err := grpc.NewClient("passthrough:///bufconn", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close(); server.Stop(); _ = listener.Close() }) + return newServiceDiscoveryClient(conn) +} + +func TestDiscoverySnapshotAndWatchPreserveRenewalWithdrawalAndEOF(t *testing.T) { + expiry := time.Now().Add(10 * time.Second).UTC() + initial := discoveryView("revision-1", expiry) + renewal := discoveryView("revision-2", expiry.Add(4*time.Second)) + empty := &pb.ServiceRouteSnapshot{Revision: "revision-3"} + client := discoveryClientForTest(t, &discoveryServer{views: []*pb.ServiceRouteSnapshot{initial, renewal, empty}}) + snapshot, err := client.Snapshot(context.Background(), "default") + require.NoError(t, err) + require.Equal(t, "demo.example.com", snapshot.Routes[0].Hostname) + require.Equal(t, expiry, snapshot.Routes[0].ExpiresAt) + watch, err := client.Watch(context.Background(), "default") + require.NoError(t, err) + defer watch.Close() + first, err := watch.Recv() + require.NoError(t, err) + second, err := watch.Recv() + require.NoError(t, err) + require.NotEqual(t, first.Revision, second.Revision) + require.Equal(t, first.Routes[0].InstallationVersion, second.Routes[0].InstallationVersion) + require.NotEqual(t, first.Routes[0].ExpiresAt, second.Routes[0].ExpiresAt) + withdrawn, err := watch.Recv() + require.NoError(t, err) + require.Empty(t, withdrawn.Routes) + none, err := watch.Recv() + require.ErrorIs(t, err, io.EOF) + require.Nil(t, none) + _, err = client.Snapshot(context.Background(), "") + require.True(t, IsInvalidArgument(err)) + _, err = client.Watch(context.Background(), "") + require.True(t, IsInvalidArgument(err)) + _, err = client.Snapshot(context.Background(), "other") + require.True(t, IsPermissionDenied(err)) +} + +func TestDiscoveryErrorsAreNotEmptySnapshotsAndCloseCancelsRPC(t *testing.T) { + view := discoveryView("revision", time.Now().Add(time.Second)) + client := discoveryClientForTest(t, &discoveryServer{views: []*pb.ServiceRouteSnapshot{view}, terminal: status.Error(codes.PermissionDenied, "revoked")}) + watch, err := client.Watch(context.Background(), "default") + require.NoError(t, err) + _, err = watch.Recv() + require.NoError(t, err) + next, err := watch.Recv() + require.Nil(t, next) + require.True(t, IsPermissionDenied(err)) + mock := &discoveryServer{views: []*pb.ServiceRouteSnapshot{view}, block: true, canceled: make(chan struct{})} + client = discoveryClientForTest(t, mock) + watch, err = client.Watch(context.Background(), "default") + require.NoError(t, err) + _, err = watch.Recv() + require.NoError(t, err) + watch.Close() + watch.Close() + _, err = watch.Recv() + require.True(t, IsCancelled(err)) + select { + case <-mock.canceled: + case <-time.After(time.Second): + t.Fatal("watch cancellation did not reach server") + } +} + +func TestDiscoveryScopesPreserveWireSelectionAndRejectEscapingViews(t *testing.T) { + for _, all := range []bool{false, true} { + t.Run(map[bool]string{false: "selected", true: "all"}[all], func(t *testing.T) { + seen := make(chan *pb.GetServiceRouteSnapshotRequest, 2) + view := discoveryView("r", time.Now().Add(time.Minute)) + if !all { + other := discoveryView("r", time.Now().Add(time.Minute)).Routes[0] + other.Workspace = "team-b" + other.Hostname = "team-b.example.com" + view.Routes = append(view.Routes, other) + } + check := func(req *pb.GetServiceRouteSnapshotRequest) error { seen <- req; return nil } + client := discoveryClientForTest(t, &discoveryServer{views: []*pb.ServiceRouteSnapshot{view}, scopeCheck: check}) + scope := ServiceRouteScope{Workspaces: []string{"team-b", "default"}} + if all { + scope = ServiceRouteScope{AllWorkspaces: true} + } + _, err := client.SnapshotFor(context.Background(), scope) + require.NoError(t, err) + request := <-seen + if all { + require.NotNil(t, request.GetWorkspaceScope().GetAllWorkspaces()) + } else { + require.Equal(t, []string{"default", "team-b"}, request.Workspaces) + require.Equal(t, []string{"team-b", "default"}, scope.Workspaces) + } + watch, err := client.WatchFor(context.Background(), scope) + require.NoError(t, err) + defer watch.Close() + if !all { + scope.Workspaces[0] = "changed" + } + _, err = watch.Recv() + require.NoError(t, err) + <-seen + }) + } + for _, scope := range []ServiceRouteScope{{}, {AllWorkspaces: true, Workspaces: []string{"default"}}, {Workspaces: []string{"default", "default"}}, {Workspaces: []string{"../bad"}}} { + _, err := discoveryScope(scope) + require.Error(t, err) + } + view := discoveryView("r", time.Now().Add(time.Minute)) + view.Routes[0].Workspace = "outside" + client := discoveryClientForTest(t, &discoveryServer{views: []*pb.ServiceRouteSnapshot{view}, scopeCheck: func(*pb.GetServiceRouteSnapshotRequest) error { return nil }}) + _, err := client.SnapshotFor(context.Background(), ServiceRouteScope{Workspaces: []string{"default", "team-b"}}) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/types/service_discovery.go b/sdk/go/openshell/v1/types/service_discovery.go new file mode 100644 index 0000000000..7a988d6fcf --- /dev/null +++ b/sdk/go/openshell/v1/types/service_discovery.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// ServiceRouteEndpoint is the trusted supervisor app listener, not a workload port. +type ServiceRouteEndpoint struct { + Host string + Port uint32 +} + +// DiscoveredServiceRoute is accepted installation evidence, not application health. +// Preserve Hostname as TLS SNI and HTTP authority when using Endpoint. +type DiscoveredServiceRoute struct { + Workspace string + Sandbox string + Name string + Hostname string + Endpoint ServiceRouteEndpoint + InstallationVersion string + DeclarationRevision uint64 + ExpiresAt time.Time +} + +// ServiceRouteSnapshot replaces the complete selected route view. Revision is +// an opaque equality token, including renewal-only expiry changes, not a cursor. +type ServiceRouteSnapshot struct { + Revision string + Routes []DiscoveredServiceRoute +} + +// ServiceRouteScope selects one workspace, a finite named set, or all active workspaces. +// AllWorkspaces is mutually exclusive with Workspaces; neither may be omitted. +type ServiceRouteScope struct { + Workspaces []string + AllWorkspaces bool +}