|
| 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 | + "net" |
| 10 | + "testing" |
| 11 | + "time" |
| 12 | + |
| 13 | + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" |
| 14 | + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" |
| 15 | + "github.com/stretchr/testify/require" |
| 16 | + "google.golang.org/grpc" |
| 17 | + "google.golang.org/grpc/codes" |
| 18 | + "google.golang.org/grpc/credentials/insecure" |
| 19 | + "google.golang.org/grpc/status" |
| 20 | + "google.golang.org/grpc/test/bufconn" |
| 21 | + "google.golang.org/protobuf/types/known/timestamppb" |
| 22 | +) |
| 23 | + |
| 24 | +type discoveryServer struct { |
| 25 | + pb.UnimplementedOpenShellServer |
| 26 | + views []*pb.ServiceRouteSnapshot |
| 27 | + terminal error |
| 28 | + block bool |
| 29 | + canceled chan struct{} |
| 30 | +} |
| 31 | + |
| 32 | +func discoveryView(revision string, expires time.Time) *pb.ServiceRouteSnapshot { |
| 33 | + return &pb.ServiceRouteSnapshot{Revision: revision, Routes: []*pb.DiscoveredServiceRoute{{ |
| 34 | + Workspace: "default", Sandbox: "demo", Name: "web", Hostname: "demo.example.com", |
| 35 | + Endpoint: &pb.ServiceRouteEndpoint{Host: "10.0.0.1", Port: 9443}, |
| 36 | + InstallationVersion: "owner-1", DeclarationRevision: 7, ExpiresAt: timestamppb.New(expires), |
| 37 | + }}} |
| 38 | +} |
| 39 | + |
| 40 | +func (s *discoveryServer) GetServiceRouteSnapshot(_ context.Context, req *pb.GetServiceRouteSnapshotRequest) (*pb.GetServiceRouteSnapshotResponse, error) { |
| 41 | + if req.GetWorkspaceScope().GetWorkspace() != "default" { |
| 42 | + return nil, status.Error(codes.PermissionDenied, "workspace denied") |
| 43 | + } |
| 44 | + return &pb.GetServiceRouteSnapshotResponse{Snapshot: s.views[0]}, nil |
| 45 | +} |
| 46 | +func (s *discoveryServer) WatchServiceRoutes(req *pb.WatchServiceRoutesRequest, stream grpc.ServerStreamingServer[pb.WatchServiceRoutesResponse]) error { |
| 47 | + if _, ok := req.GetWorkspaceScope().GetSelection().(*dm.WorkspaceSelector_Workspace); !ok { |
| 48 | + return status.Error(codes.InvalidArgument, "explicit workspace required") |
| 49 | + } |
| 50 | + for _, view := range s.views { |
| 51 | + if err := stream.Send(&pb.WatchServiceRoutesResponse{Snapshot: view}); err != nil { |
| 52 | + return err |
| 53 | + } |
| 54 | + } |
| 55 | + if s.block { |
| 56 | + <-stream.Context().Done() |
| 57 | + close(s.canceled) |
| 58 | + return status.FromContextError(stream.Context().Err()).Err() |
| 59 | + } |
| 60 | + return s.terminal |
| 61 | +} |
| 62 | + |
| 63 | +func discoveryClientForTest(t *testing.T, mock *discoveryServer) *serviceDiscoveryClient { |
| 64 | + t.Helper() |
| 65 | + listener := bufconn.Listen(bufSize) |
| 66 | + server := grpc.NewServer() |
| 67 | + pb.RegisterOpenShellServer(server, mock) |
| 68 | + go func() { _ = server.Serve(listener) }() |
| 69 | + conn, err := grpc.NewClient("passthrough:///bufconn", grpc.WithContextDialer(func(context.Context, string) (net.Conn, error) { return listener.Dial() }), grpc.WithTransportCredentials(insecure.NewCredentials())) |
| 70 | + require.NoError(t, err) |
| 71 | + t.Cleanup(func() { _ = conn.Close(); server.Stop(); _ = listener.Close() }) |
| 72 | + return newServiceDiscoveryClient(conn) |
| 73 | +} |
| 74 | + |
| 75 | +func TestDiscoverySnapshotAndWatchPreserveRenewalWithdrawalAndEOF(t *testing.T) { |
| 76 | + expiry := time.Now().Add(10 * time.Second).UTC() |
| 77 | + initial := discoveryView("revision-1", expiry) |
| 78 | + renewal := discoveryView("revision-2", expiry.Add(4*time.Second)) |
| 79 | + empty := &pb.ServiceRouteSnapshot{Revision: "revision-3"} |
| 80 | + client := discoveryClientForTest(t, &discoveryServer{views: []*pb.ServiceRouteSnapshot{initial, renewal, empty}}) |
| 81 | + snapshot, err := client.Snapshot(context.Background(), "default") |
| 82 | + require.NoError(t, err) |
| 83 | + require.Equal(t, "demo.example.com", snapshot.Routes[0].Hostname) |
| 84 | + require.Equal(t, expiry, snapshot.Routes[0].ExpiresAt) |
| 85 | + watch, err := client.Watch(context.Background(), "default") |
| 86 | + require.NoError(t, err) |
| 87 | + defer watch.Close() |
| 88 | + first, err := watch.Recv() |
| 89 | + require.NoError(t, err) |
| 90 | + second, err := watch.Recv() |
| 91 | + require.NoError(t, err) |
| 92 | + require.NotEqual(t, first.Revision, second.Revision) |
| 93 | + require.Equal(t, first.Routes[0].InstallationVersion, second.Routes[0].InstallationVersion) |
| 94 | + require.NotEqual(t, first.Routes[0].ExpiresAt, second.Routes[0].ExpiresAt) |
| 95 | + withdrawn, err := watch.Recv() |
| 96 | + require.NoError(t, err) |
| 97 | + require.Empty(t, withdrawn.Routes) |
| 98 | + none, err := watch.Recv() |
| 99 | + require.ErrorIs(t, err, io.EOF) |
| 100 | + require.Nil(t, none) |
| 101 | + _, err = client.Snapshot(context.Background(), "") |
| 102 | + require.True(t, IsInvalidArgument(err)) |
| 103 | + _, err = client.Watch(context.Background(), "") |
| 104 | + require.True(t, IsInvalidArgument(err)) |
| 105 | + _, err = client.Snapshot(context.Background(), "other") |
| 106 | + require.True(t, IsPermissionDenied(err)) |
| 107 | +} |
| 108 | + |
| 109 | +func TestDiscoveryErrorsAreNotEmptySnapshotsAndCloseCancelsRPC(t *testing.T) { |
| 110 | + view := discoveryView("revision", time.Now().Add(time.Second)) |
| 111 | + client := discoveryClientForTest(t, &discoveryServer{views: []*pb.ServiceRouteSnapshot{view}, terminal: status.Error(codes.PermissionDenied, "revoked")}) |
| 112 | + watch, err := client.Watch(context.Background(), "default") |
| 113 | + require.NoError(t, err) |
| 114 | + _, err = watch.Recv() |
| 115 | + require.NoError(t, err) |
| 116 | + next, err := watch.Recv() |
| 117 | + require.Nil(t, next) |
| 118 | + require.True(t, IsPermissionDenied(err)) |
| 119 | + mock := &discoveryServer{views: []*pb.ServiceRouteSnapshot{view}, block: true, canceled: make(chan struct{})} |
| 120 | + client = discoveryClientForTest(t, mock) |
| 121 | + watch, err = client.Watch(context.Background(), "default") |
| 122 | + require.NoError(t, err) |
| 123 | + _, err = watch.Recv() |
| 124 | + require.NoError(t, err) |
| 125 | + watch.Close() |
| 126 | + watch.Close() |
| 127 | + _, err = watch.Recv() |
| 128 | + require.True(t, IsCancelled(err)) |
| 129 | + select { |
| 130 | + case <-mock.canceled: |
| 131 | + case <-time.After(time.Second): |
| 132 | + t.Fatal("watch cancellation did not reach server") |
| 133 | + } |
| 134 | +} |
0 commit comments