From 3e46ae8f7224bc086a386eb1b7ebd2a78b9b9a71 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Roland=20Hu=C3=9F?= Date: Tue, 11 Aug 2026 14:31:42 +0200 Subject: [PATCH] feat(sdk/go): complete Go SDK with domain clients, auth, and hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add the Go SDK implementation covering all gateway RPC services with domain-typed clients, comprehensive OIDC authentication flows, fake test doubles, and proto converters. Domain clients: Sandbox, Provider, Exec, File, TCP, SSH, Policy, Profile, Health, Service, Config, Workspace, Inference, Refresh. Each client validates inputs, resolves sandboxes by name, and converts between domain types and proto at the boundary. Auth: OIDC authorization code (PKCE), device code (RFC 8628), and client credentials (RFC 6749 Section 4.4) flows with gateway config auto-resolution. Token refresh with singleflight deduplication and exponential backoff. Edge tunnel proxy for gRPC-over-WebSocket. Gateway: On-disk gateway discovery with user/system directory precedence, lazy token loading, and auth mode mapping. Testing: In-memory fake client with deep-copy isolation, watch broadcasting with filtering, and workspace-scoped object stores. Bufconn-based gRPC tests for all domain clients. Ref: #2044 Signed-off-by: Roland Huß --- mise.lock | 14 +- sdk/go/.golangci.yml | 43 + sdk/go/README.md | 320 +++++ sdk/go/buf.gen.yaml | 27 +- sdk/go/docs/book.toml | 23 + sdk/go/docs/src/SUMMARY.md | 37 + sdk/go/docs/src/api/client.md | 76 ++ sdk/go/docs/src/api/config.md | 52 + sdk/go/docs/src/api/edge.md | 91 ++ sdk/go/docs/src/api/exec.md | 161 +++ sdk/go/docs/src/api/fake.md | 112 ++ sdk/go/docs/src/api/files.md | 36 + sdk/go/docs/src/api/gateway.md | 186 +++ sdk/go/docs/src/api/health.md | 32 + sdk/go/docs/src/api/oidc.md | 157 +++ sdk/go/docs/src/api/overview.md | 61 + sdk/go/docs/src/api/policy.md | 93 ++ sdk/go/docs/src/api/profiles.md | 85 ++ sdk/go/docs/src/api/providers.md | 151 +++ sdk/go/docs/src/api/refresh.md | 57 + sdk/go/docs/src/api/sandboxes.md | 210 +++ sdk/go/docs/src/api/services.md | 47 + sdk/go/docs/src/api/ssh.md | 55 + sdk/go/docs/src/api/tcp.md | 60 + sdk/go/docs/src/architecture.md | 118 ++ sdk/go/docs/src/error-handling.md | 195 +++ sdk/go/docs/src/getting-started.md | 123 ++ sdk/go/docs/src/introduction.md | 34 + sdk/go/docs/src/testing.md | 184 +++ sdk/go/docs/theme/custom.css | 179 +++ sdk/go/go.mod | 18 +- sdk/go/go.sum | 48 +- sdk/go/mise.toml | 154 +++ sdk/go/openshell/v1/auth_refresh.go | 91 +- sdk/go/openshell/v1/auth_refresh_test.go | 152 ++- sdk/go/openshell/v1/client.go | 50 +- sdk/go/openshell/v1/client_test.go | 7 + sdk/go/openshell/v1/config.go | 9 - sdk/go/openshell/v1/config_client.go | 67 + sdk/go/openshell/v1/config_client_test.go | 577 +++++++++ .../v1/{grpc_errors.go => context_errors.go} | 2 - sdk/go/openshell/v1/context_errors_test.go | 49 + sdk/go/openshell/v1/doc.go | 135 +- sdk/go/openshell/v1/edge/cloudflare.go | 29 + sdk/go/openshell/v1/edge/cloudflare_test.go | 88 ++ sdk/go/openshell/v1/edge/doc.go | 88 ++ sdk/go/openshell/v1/edge/tunnel.go | 295 +++++ sdk/go/openshell/v1/edge/tunnel_test.go | 500 +++++++ sdk/go/openshell/v1/errors.go | 2 +- sdk/go/openshell/v1/errors_test.go | 4 +- sdk/go/openshell/v1/example_fake_test.go | 197 +++ sdk/go/openshell/v1/example_test.go | 184 +++ sdk/go/openshell/v1/exec_client.go | 334 +++++ sdk/go/openshell/v1/exec_client_test.go | 654 ++++++++++ sdk/go/openshell/v1/fake/broadcaster.go | 155 +++ sdk/go/openshell/v1/fake/broadcaster_test.go | 195 +++ sdk/go/openshell/v1/fake/config.go | 56 + sdk/go/openshell/v1/fake/config_test.go | 78 ++ sdk/go/openshell/v1/fake/doc.go | 37 + sdk/go/openshell/v1/fake/exec.go | 57 + sdk/go/openshell/v1/fake/exec_test.go | 70 + sdk/go/openshell/v1/fake/fake.go | 212 +++ sdk/go/openshell/v1/fake/fake_test.go | 231 ++++ sdk/go/openshell/v1/fake/file.go | 52 + sdk/go/openshell/v1/fake/file_test.go | 52 + sdk/go/openshell/v1/fake/health.go | 103 ++ sdk/go/openshell/v1/fake/health_test.go | 153 +++ sdk/go/openshell/v1/fake/inference.go | 119 ++ sdk/go/openshell/v1/fake/inference_test.go | 273 ++++ sdk/go/openshell/v1/fake/policy.go | 249 ++++ sdk/go/openshell/v1/fake/policy_test.go | 292 +++++ sdk/go/openshell/v1/fake/profile.go | 73 ++ sdk/go/openshell/v1/fake/profile_test.go | 100 ++ sdk/go/openshell/v1/fake/provider.go | 178 +++ sdk/go/openshell/v1/fake/provider_test.go | 276 ++++ sdk/go/openshell/v1/fake/refresh.go | 57 + sdk/go/openshell/v1/fake/refresh_test.go | 78 ++ sdk/go/openshell/v1/fake/sandbox.go | 600 +++++++++ sdk/go/openshell/v1/fake/sandbox_test.go | 932 ++++++++++++++ sdk/go/openshell/v1/fake/service.go | 57 + sdk/go/openshell/v1/fake/service_test.go | 72 ++ sdk/go/openshell/v1/fake/ssh.go | 58 + sdk/go/openshell/v1/fake/ssh_test.go | 87 ++ sdk/go/openshell/v1/fake/store.go | 174 +++ sdk/go/openshell/v1/fake/store_test.go | 293 +++++ sdk/go/openshell/v1/fake/tcp.go | 61 + sdk/go/openshell/v1/fake/tcp_test.go | 104 ++ sdk/go/openshell/v1/fake/workspace.go | 172 +++ sdk/go/openshell/v1/fake/workspace_test.go | 293 +++++ sdk/go/openshell/v1/file.go | 9 +- sdk/go/openshell/v1/file_client.go | 125 ++ sdk/go/openshell/v1/file_client_test.go | 343 +++++ sdk/go/openshell/v1/gateway/config.go | 157 +++ sdk/go/openshell/v1/gateway/config_test.go | 209 +++ sdk/go/openshell/v1/gateway/doc.go | 75 ++ sdk/go/openshell/v1/gateway/errors.go | 36 + sdk/go/openshell/v1/gateway/errors_test.go | 88 ++ sdk/go/openshell/v1/gateway/gateway.go | 172 +++ sdk/go/openshell/v1/gateway/gateway_test.go | 462 +++++++ sdk/go/openshell/v1/gateway/options.go | 37 + sdk/go/openshell/v1/gateway/paths.go | 168 +++ sdk/go/openshell/v1/gateway/paths_test.go | 217 ++++ sdk/go/openshell/v1/gateway/token.go | 176 +++ sdk/go/openshell/v1/gateway/token_test.go | 252 ++++ sdk/go/openshell/v1/health.go | 24 +- sdk/go/openshell/v1/health_client.go | 48 + sdk/go/openshell/v1/health_client_test.go | 263 ++++ sdk/go/openshell/v1/inference.go | 38 + sdk/go/openshell/v1/inference_client.go | 72 ++ sdk/go/openshell/v1/inference_client_test.go | 405 ++++++ sdk/go/openshell/v1/integration_test.go | 55 +- .../openshell/v1/internal/converter/copy.go | 20 +- .../v1/internal/converter/coverage_test.go | 115 +- .../openshell/v1/internal/converter/errors.go | 4 +- .../openshell/v1/internal/converter/exec.go | 98 ++ .../v1/internal/converter/exec_test.go | 194 +++ .../openshell/v1/internal/converter/health.go | 68 + .../v1/internal/converter/health_test.go | 162 +++ .../v1/internal/converter/inference.go | 75 ++ .../v1/internal/converter/inference_test.go | 168 +++ .../v1/internal/converter/network_policy.go | 75 +- .../internal/converter/network_policy_test.go | 344 +++++ .../openshell/v1/internal/converter/policy.go | 81 ++ .../v1/internal/converter/policy_test.go | 709 ++++++++++ .../v1/internal/converter/profile.go | 409 ++++++ .../v1/internal/converter/profile_test.go | 616 +++++++++ .../v1/internal/converter/provider_test.go | 50 + .../v1/internal/converter/refresh.go | 96 ++ .../v1/internal/converter/refresh_test.go | 168 +++ .../v1/internal/converter/sandbox.go | 88 +- .../v1/internal/converter/sandbox_test.go | 84 +- .../v1/internal/converter/service.go | 59 + .../v1/internal/converter/service_test.go | 131 ++ .../v1/internal/converter/setting.go | 306 +++++ .../v1/internal/converter/setting_test.go | 840 ++++++++++++ sdk/go/openshell/v1/internal/converter/ssh.go | 42 + .../v1/internal/converter/ssh_test.go | 113 ++ .../v1/internal/converter/time_test.go | 2 +- .../v1/internal/converter/workspace.go | 97 ++ .../v1/internal/converter/workspace_test.go | 209 +++ sdk/go/openshell/v1/internal/grpc/conn.go | 3 + .../openshell/v1/internal/grpc/conn_test.go | 59 +- sdk/go/openshell/v1/oidc/authcode.go | 236 ++++ sdk/go/openshell/v1/oidc/authcode_test.go | 371 ++++++ sdk/go/openshell/v1/oidc/browser.go | 59 + sdk/go/openshell/v1/oidc/browser_test.go | 55 + sdk/go/openshell/v1/oidc/credentials.go | 150 +++ sdk/go/openshell/v1/oidc/credentials_test.go | 277 ++++ sdk/go/openshell/v1/oidc/device.go | 292 +++++ sdk/go/openshell/v1/oidc/device_test.go | 698 ++++++++++ sdk/go/openshell/v1/oidc/discovery.go | 174 +++ sdk/go/openshell/v1/oidc/discovery_test.go | 263 ++++ sdk/go/openshell/v1/oidc/doc.go | 78 ++ sdk/go/openshell/v1/oidc/errors.go | 43 + sdk/go/openshell/v1/oidc/errors_test.go | 107 ++ sdk/go/openshell/v1/oidc/example_test.go | 160 +++ sdk/go/openshell/v1/oidc/keyboard.go | 97 ++ sdk/go/openshell/v1/oidc/keyboard_test.go | 172 +++ sdk/go/openshell/v1/oidc/oidc.go | 228 ++++ sdk/go/openshell/v1/oidc/oidc_test.go | 496 +++++++ sdk/go/openshell/v1/oidc/options.go | 170 +++ sdk/go/openshell/v1/oidc/options_test.go | 155 +++ sdk/go/openshell/v1/oidc/token.go | 138 ++ sdk/go/openshell/v1/oidc/token_test.go | 194 +++ sdk/go/openshell/v1/options.go | 9 - sdk/go/openshell/v1/policy.go | 78 +- sdk/go/openshell/v1/policy_client.go | 167 +++ sdk/go/openshell/v1/policy_client_test.go | 1027 +++++++++++++++ sdk/go/openshell/v1/profile.go | 6 - sdk/go/openshell/v1/profile_client.go | 154 +++ sdk/go/openshell/v1/profile_client_test.go | 570 ++++++++ sdk/go/openshell/v1/provider_client.go | 130 ++ sdk/go/openshell/v1/provider_client_test.go | 312 +++++ sdk/go/openshell/v1/refresh.go | 6 - sdk/go/openshell/v1/refresh_client.go | 71 + sdk/go/openshell/v1/refresh_client_test.go | 426 ++++++ sdk/go/openshell/v1/sandbox.go | 9 +- sdk/go/openshell/v1/sandbox_client.go | 65 +- sdk/go/openshell/v1/sandbox_client_test.go | 55 +- sdk/go/openshell/v1/service.go | 4 - sdk/go/openshell/v1/service_client.go | 87 ++ sdk/go/openshell/v1/service_client_test.go | 322 +++++ sdk/go/openshell/v1/ssh.go | 21 - sdk/go/openshell/v1/ssh_client.go | 161 +++ sdk/go/openshell/v1/ssh_client_test.go | 612 +++++++++ sdk/go/openshell/v1/stub_clients.go | 195 --- sdk/go/openshell/v1/tcp.go | 39 +- sdk/go/openshell/v1/tcp_client.go | 360 ++++++ sdk/go/openshell/v1/tcp_client_test.go | 1143 +++++++++++++++++ sdk/go/openshell/v1/types.go | 3 - sdk/go/openshell/v1/types/config.go | 8 - sdk/go/openshell/v1/types/errors.go | 2 +- sdk/go/openshell/v1/types/health.go | 34 + sdk/go/openshell/v1/types/inference.go | 67 + sdk/go/openshell/v1/types/network_policy.go | 19 +- sdk/go/openshell/v1/types/options.go | 17 +- sdk/go/openshell/v1/types/policy.go | 53 + sdk/go/openshell/v1/types/profile.go | 55 + sdk/go/openshell/v1/types/sandbox.go | 2 +- sdk/go/openshell/v1/types/service.go | 1 + sdk/go/openshell/v1/types/setting.go | 7 + sdk/go/openshell/v1/types/types.go | 13 +- sdk/go/openshell/v1/types/workspace.go | 51 + sdk/go/openshell/v1/watch_test.go | 5 +- sdk/go/openshell/v1/workspace.go | 47 + sdk/go/openshell/v1/workspace_client.go | 162 +++ sdk/go/openshell/v1/workspace_test.go | 468 +++++++ sdk/go/proto/inferencev1/inference.pb.go | 1018 +++++++++++++++ sdk/go/proto/inferencev1/inference_grpc.pb.go | 256 ++++ tasks/go.toml | 35 +- 210 files changed, 33938 insertions(+), 745 deletions(-) create mode 100644 sdk/go/.golangci.yml create mode 100644 sdk/go/README.md create mode 100644 sdk/go/docs/book.toml create mode 100644 sdk/go/docs/src/SUMMARY.md create mode 100644 sdk/go/docs/src/api/client.md create mode 100644 sdk/go/docs/src/api/config.md create mode 100644 sdk/go/docs/src/api/edge.md create mode 100644 sdk/go/docs/src/api/exec.md create mode 100644 sdk/go/docs/src/api/fake.md create mode 100644 sdk/go/docs/src/api/files.md create mode 100644 sdk/go/docs/src/api/gateway.md create mode 100644 sdk/go/docs/src/api/health.md create mode 100644 sdk/go/docs/src/api/oidc.md create mode 100644 sdk/go/docs/src/api/overview.md create mode 100644 sdk/go/docs/src/api/policy.md create mode 100644 sdk/go/docs/src/api/profiles.md create mode 100644 sdk/go/docs/src/api/providers.md create mode 100644 sdk/go/docs/src/api/refresh.md create mode 100644 sdk/go/docs/src/api/sandboxes.md create mode 100644 sdk/go/docs/src/api/services.md create mode 100644 sdk/go/docs/src/api/ssh.md create mode 100644 sdk/go/docs/src/api/tcp.md create mode 100644 sdk/go/docs/src/architecture.md create mode 100644 sdk/go/docs/src/error-handling.md create mode 100644 sdk/go/docs/src/getting-started.md create mode 100644 sdk/go/docs/src/introduction.md create mode 100644 sdk/go/docs/src/testing.md create mode 100644 sdk/go/docs/theme/custom.css create mode 100644 sdk/go/mise.toml create mode 100644 sdk/go/openshell/v1/config_client.go create mode 100644 sdk/go/openshell/v1/config_client_test.go rename sdk/go/openshell/v1/{grpc_errors.go => context_errors.go} (82%) create mode 100644 sdk/go/openshell/v1/context_errors_test.go create mode 100644 sdk/go/openshell/v1/edge/cloudflare.go create mode 100644 sdk/go/openshell/v1/edge/cloudflare_test.go create mode 100644 sdk/go/openshell/v1/edge/doc.go create mode 100644 sdk/go/openshell/v1/edge/tunnel.go create mode 100644 sdk/go/openshell/v1/edge/tunnel_test.go create mode 100644 sdk/go/openshell/v1/example_fake_test.go create mode 100644 sdk/go/openshell/v1/example_test.go create mode 100644 sdk/go/openshell/v1/exec_client.go create mode 100644 sdk/go/openshell/v1/exec_client_test.go create mode 100644 sdk/go/openshell/v1/fake/broadcaster.go create mode 100644 sdk/go/openshell/v1/fake/broadcaster_test.go create mode 100644 sdk/go/openshell/v1/fake/config.go create mode 100644 sdk/go/openshell/v1/fake/config_test.go create mode 100644 sdk/go/openshell/v1/fake/doc.go create mode 100644 sdk/go/openshell/v1/fake/exec.go create mode 100644 sdk/go/openshell/v1/fake/exec_test.go create mode 100644 sdk/go/openshell/v1/fake/fake.go create mode 100644 sdk/go/openshell/v1/fake/fake_test.go create mode 100644 sdk/go/openshell/v1/fake/file.go create mode 100644 sdk/go/openshell/v1/fake/file_test.go create mode 100644 sdk/go/openshell/v1/fake/health.go create mode 100644 sdk/go/openshell/v1/fake/health_test.go create mode 100644 sdk/go/openshell/v1/fake/inference.go create mode 100644 sdk/go/openshell/v1/fake/inference_test.go create mode 100644 sdk/go/openshell/v1/fake/policy.go create mode 100644 sdk/go/openshell/v1/fake/policy_test.go create mode 100644 sdk/go/openshell/v1/fake/profile.go create mode 100644 sdk/go/openshell/v1/fake/profile_test.go create mode 100644 sdk/go/openshell/v1/fake/provider.go create mode 100644 sdk/go/openshell/v1/fake/provider_test.go create mode 100644 sdk/go/openshell/v1/fake/refresh.go create mode 100644 sdk/go/openshell/v1/fake/refresh_test.go create mode 100644 sdk/go/openshell/v1/fake/sandbox.go create mode 100644 sdk/go/openshell/v1/fake/sandbox_test.go create mode 100644 sdk/go/openshell/v1/fake/service.go create mode 100644 sdk/go/openshell/v1/fake/service_test.go create mode 100644 sdk/go/openshell/v1/fake/ssh.go create mode 100644 sdk/go/openshell/v1/fake/ssh_test.go create mode 100644 sdk/go/openshell/v1/fake/store.go create mode 100644 sdk/go/openshell/v1/fake/store_test.go create mode 100644 sdk/go/openshell/v1/fake/tcp.go create mode 100644 sdk/go/openshell/v1/fake/tcp_test.go create mode 100644 sdk/go/openshell/v1/fake/workspace.go create mode 100644 sdk/go/openshell/v1/fake/workspace_test.go create mode 100644 sdk/go/openshell/v1/file_client.go create mode 100644 sdk/go/openshell/v1/file_client_test.go create mode 100644 sdk/go/openshell/v1/gateway/config.go create mode 100644 sdk/go/openshell/v1/gateway/config_test.go create mode 100644 sdk/go/openshell/v1/gateway/doc.go create mode 100644 sdk/go/openshell/v1/gateway/errors.go create mode 100644 sdk/go/openshell/v1/gateway/errors_test.go create mode 100644 sdk/go/openshell/v1/gateway/gateway.go create mode 100644 sdk/go/openshell/v1/gateway/gateway_test.go create mode 100644 sdk/go/openshell/v1/gateway/options.go create mode 100644 sdk/go/openshell/v1/gateway/paths.go create mode 100644 sdk/go/openshell/v1/gateway/paths_test.go create mode 100644 sdk/go/openshell/v1/gateway/token.go create mode 100644 sdk/go/openshell/v1/gateway/token_test.go create mode 100644 sdk/go/openshell/v1/health_client.go create mode 100644 sdk/go/openshell/v1/health_client_test.go create mode 100644 sdk/go/openshell/v1/inference.go create mode 100644 sdk/go/openshell/v1/inference_client.go create mode 100644 sdk/go/openshell/v1/inference_client_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/exec.go create mode 100644 sdk/go/openshell/v1/internal/converter/exec_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/health.go create mode 100644 sdk/go/openshell/v1/internal/converter/health_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/inference.go create mode 100644 sdk/go/openshell/v1/internal/converter/inference_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/network_policy_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/policy_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/profile.go create mode 100644 sdk/go/openshell/v1/internal/converter/profile_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/refresh.go create mode 100644 sdk/go/openshell/v1/internal/converter/refresh_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/service.go create mode 100644 sdk/go/openshell/v1/internal/converter/service_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/setting.go create mode 100644 sdk/go/openshell/v1/internal/converter/setting_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/ssh.go create mode 100644 sdk/go/openshell/v1/internal/converter/ssh_test.go create mode 100644 sdk/go/openshell/v1/internal/converter/workspace.go create mode 100644 sdk/go/openshell/v1/internal/converter/workspace_test.go create mode 100644 sdk/go/openshell/v1/oidc/authcode.go create mode 100644 sdk/go/openshell/v1/oidc/authcode_test.go create mode 100644 sdk/go/openshell/v1/oidc/browser.go create mode 100644 sdk/go/openshell/v1/oidc/browser_test.go create mode 100644 sdk/go/openshell/v1/oidc/credentials.go create mode 100644 sdk/go/openshell/v1/oidc/credentials_test.go create mode 100644 sdk/go/openshell/v1/oidc/device.go create mode 100644 sdk/go/openshell/v1/oidc/device_test.go create mode 100644 sdk/go/openshell/v1/oidc/discovery.go create mode 100644 sdk/go/openshell/v1/oidc/discovery_test.go create mode 100644 sdk/go/openshell/v1/oidc/doc.go create mode 100644 sdk/go/openshell/v1/oidc/errors.go create mode 100644 sdk/go/openshell/v1/oidc/errors_test.go create mode 100644 sdk/go/openshell/v1/oidc/example_test.go create mode 100644 sdk/go/openshell/v1/oidc/keyboard.go create mode 100644 sdk/go/openshell/v1/oidc/keyboard_test.go create mode 100644 sdk/go/openshell/v1/oidc/oidc.go create mode 100644 sdk/go/openshell/v1/oidc/oidc_test.go create mode 100644 sdk/go/openshell/v1/oidc/options.go create mode 100644 sdk/go/openshell/v1/oidc/options_test.go create mode 100644 sdk/go/openshell/v1/oidc/token.go create mode 100644 sdk/go/openshell/v1/oidc/token_test.go create mode 100644 sdk/go/openshell/v1/policy_client.go create mode 100644 sdk/go/openshell/v1/policy_client_test.go create mode 100644 sdk/go/openshell/v1/profile_client.go create mode 100644 sdk/go/openshell/v1/profile_client_test.go create mode 100644 sdk/go/openshell/v1/provider_client.go create mode 100644 sdk/go/openshell/v1/provider_client_test.go create mode 100644 sdk/go/openshell/v1/refresh_client.go create mode 100644 sdk/go/openshell/v1/refresh_client_test.go create mode 100644 sdk/go/openshell/v1/service_client.go create mode 100644 sdk/go/openshell/v1/service_client_test.go create mode 100644 sdk/go/openshell/v1/ssh_client.go create mode 100644 sdk/go/openshell/v1/ssh_client_test.go delete mode 100644 sdk/go/openshell/v1/stub_clients.go create mode 100644 sdk/go/openshell/v1/tcp_client.go create mode 100644 sdk/go/openshell/v1/tcp_client_test.go create mode 100644 sdk/go/openshell/v1/types/inference.go create mode 100644 sdk/go/openshell/v1/types/workspace.go create mode 100644 sdk/go/openshell/v1/workspace.go create mode 100644 sdk/go/openshell/v1/workspace_client.go create mode 100644 sdk/go/openshell/v1/workspace_test.go create mode 100644 sdk/go/proto/inferencev1/inference.pb.go create mode 100644 sdk/go/proto/inferencev1/inference_grpc.pb.go diff --git a/mise.lock b/mise.lock index 74067b6cf5..ee819d38a6 100644 --- a/mise.lock +++ b/mise.lock @@ -70,9 +70,6 @@ provenance = "github-attestations" version = "0.16.0" backend = "github:mozilla/sccache" -[tools."github:mozilla/sccache".options] -asset_pattern = "sccache-v*x86_64*linux*.tar.gz" - [tools."github:mozilla/sccache"."platforms.linux-arm64"] checksum = "sha256:f73a5c39f96bb6ebb89cc7915cf182260d4cbf30765322c5e793d0fe8bd80784" url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-aarch64-unknown-linux-musl.tar.gz" @@ -92,21 +89,14 @@ url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/45206041 version = "0.16.0" backend = "github:mozilla/sccache" -[tools."github:mozilla/sccache"."platforms.linux-arm64"] -checksum = "sha256:f73a5c39f96bb6ebb89cc7915cf182260d4cbf30765322c5e793d0fe8bd80784" -url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-aarch64-unknown-linux-musl.tar.gz" -url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/452060468" +[tools."github:mozilla/sccache".options] +asset_pattern = "sccache-v*x86_64*linux*.tar.gz" [tools."github:mozilla/sccache"."platforms.linux-x64"] checksum = "sha256:aec995a83ad3dff3d14b6314e08858b7b73d35ca85a5bcf3d3a9ec07dee35588" url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-x86_64-unknown-linux-musl.tar.gz" url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/452060682" -[tools."github:mozilla/sccache"."platforms.macos-arm64"] -checksum = "sha256:ded590cae2c72042c61178632906bef62d635fa20d45f8b22110a2241f430960" -url = "https://github.com/mozilla/sccache/releases/download/v0.16.0/sccache-v0.16.0-aarch64-apple-darwin.tar.gz" -url_api = "https://api.github.com/repos/mozilla/sccache/releases/assets/452060416" - [[tools."github:rust-cross/cargo-zigbuild"]] version = "0.22.3" backend = "github:rust-cross/cargo-zigbuild" diff --git a/sdk/go/.golangci.yml b/sdk/go/.golangci.yml new file mode 100644 index 0000000000..bf33432fb3 --- /dev/null +++ b/sdk/go/.golangci.yml @@ -0,0 +1,43 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +version: "2" + +run: + timeout: 5m + +linters: + enable: + - govet + - errcheck + - staticcheck + - unused + - ineffassign + - revive + - goheader + exclusions: + rules: + - path: "proto/" + linters: + - goheader + - revive + +linters-settings: + goheader: + template: |- + SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. + SPDX-License-Identifier: Apache-2.0 + revive: + rules: + - name: blank-imports + - name: exported + - name: var-naming + - name: indent-error-flow + - name: range + - name: error-return + - name: error-naming + - name: error-strings + - name: receiver-naming + - name: increment-decrement + - name: superfluous-else + - name: unreachable-code diff --git a/sdk/go/README.md b/sdk/go/README.md new file mode 100644 index 0000000000..9357ffa412 --- /dev/null +++ b/sdk/go/README.md @@ -0,0 +1,320 @@ +# OpenShell SDK for Go + +[![Go Reference](https://pkg.go.dev/badge/github.com/NVIDIA/OpenShell/sdk/go.svg)](https://pkg.go.dev/github.com/NVIDIA/OpenShell/sdk/go) +[![License](https://img.shields.io/badge/License-Apache_2.0-blue.svg)](../../LICENSE) + +> [!IMPORTANT] +> **[Read the full documentation](https://ro14nd.de/openshell-sdk-go/)** for guides, API reference with gRPC mapping, and testing patterns. + +A Go SDK for interacting with [OpenShell](https://github.com/NVIDIA/OpenShell) +servers, providing idiomatic Go bindings for shell session management, command +execution, provider configuration, and service exposure. + +## Why a Go SDK? + +Go is the language of the Kubernetes ecosystem. If you want to build an +operator, controller, or any automation that manages OpenShell resources as +native Kubernetes objects, you need a Go client. + +This SDK is modeled after +[`k8s.io/client-go`](https://github.com/kubernetes/client-go), the standard +Kubernetes client library that every Go operator developer already knows. The +patterns will look familiar: + +- **Typed sub-clients per resource**: `client.Sandboxes()`, `client.Providers()`, + `client.Exec()`, just like `clientset.CoreV1().Pods()` +- **Domain types separated from wire formats**: clean Go structs in a `types` + package, no proto leakage into the public API (like `k8s.io/api`) +- **Watch primitives**: channel-based watchers with `ResultChan()` and `Stop()`, + identical to `watch.Interface` in client-go +- **Functional options**: variadic option patterns for list filtering, + pagination, and watch configuration +- **Composable auth with token refresh**: wraps `oauth2.TokenSource` for + automatic token caching and coalesced refresh, following the k8s client-go + `cachingTokenSource` pattern +- **Fake client for testing**: an in-memory implementation of the full client + interface (like `k8s.io/client-go/kubernetes/fake`), so operators can be tested + without a real gateway + +## Quick Start + +```go +import v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + +// Connect to a gateway +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: v1.StaticToken("my-token"), +}) +if err != nil { + log.Fatal(err) +} +defer client.Close() + +// Create a sandbox and wait until it's ready +sandbox, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{ + Template: &v1.SandboxTemplate{Image: "python:3.12"}, +}, nil) +if err != nil { + log.Fatal(err) +} +sandbox, err = client.Sandboxes().WaitReady(ctx, "default", sandbox.Name) +if err != nil { + log.Fatal(err) +} + +// Run a command +result, err := client.Exec().Run(ctx, "default", sandbox.Name, + []string{"python3", "-c", "print('hello from sandbox')"}, + v1.ExecOptions{}, +) +if err != nil { + log.Fatal(err) +} +fmt.Println(string(result.Stdout)) +``` + +### With automatic token refresh + +For OIDC gateways, use `RefreshableToken` to wrap any `oauth2.TokenSource` with +automatic caching and coalesced refresh: + +```go +import "golang.org/x/oauth2" + +tokenSource := oauth2Config.TokenSource(ctx, initialToken) +auth, err := v1.RefreshableToken(tokenSource, + v1.WithLeeway(30*time.Second), +) +if err != nil { + log.Fatal(err) +} +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: auth, +}) +if err != nil { + log.Fatal(err) +} +defer client.Close() +``` + +Concurrent callers share a single refresh call. If the token source fails, the +SDK falls back to the cached token with a logged warning. See the +[Auth](https://ro14nd.de/openshell-sdk-go/api/auth.html) docs for details. + +### With edge proxy headers + +When a gateway sits behind a zero-trust reverse proxy, use `WithExtraHeaders` to +attach proxy-specific headers alongside standard auth: + +```go +base := v1.StaticToken("my-gateway-token") +auth, err := v1.WithExtraHeaders(base, map[string]string{ + "x-proxy-auth": "proxy-secret", +}) +if err != nil { + log.Fatal(err) +} +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: auth, +}) +``` + +For Cloudflare Access, use the convenience constructor in the `edge` package: + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/edge" + +auth, err := edge.CloudflareAccess(base, os.Getenv("CF_ACCESS_TOKEN")) +``` + +For gRPC behind edge proxies that reject HTTP/2, use the WebSocket tunnel: + +```go +tunnel, err := edge.NewTunnelProxy( + "wss://gateway.example.com/ws", + os.Getenv("CF_ACCESS_TOKEN"), +) +if err != nil { + log.Fatal(err) +} +defer tunnel.Close() + +client, err := v1.NewClient(v1.Config{ + Address: tunnel.Addr(), + Auth: v1.StaticToken("my-token"), + TLS: &v1.TLSConfig{Insecure: true}, // local tunnel, no TLS +}) +``` + +### OIDC Login + +The `oidc` package provides gateway-aware OIDC authentication with browser, +keyboard, device code, and client credentials flows: + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc" + +// Gateway-aware login: reads OIDC config from gateway metadata +token, err := oidc.Login(ctx, "my-gateway") +if err != nil { + log.Fatal(err) +} + +// Use the token with the SDK client +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: v1.StaticToken(token.AccessToken), +}) +``` + +For headless environments, use the device code flow: + +```go +token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-app"), +) +``` + +For service accounts, use client credentials: + +```go +token, err := oidc.ClientCredentials(ctx, + oidc.WithGateway("my-gateway"), + oidc.WithClientSecret("service-secret"), +) +``` + +See the [oidc package docs](https://pkg.go.dev/github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc) for all options and flows. + +See the [Getting Started](https://ro14nd.de/openshell-sdk-go/getting-started.html) guide for the full walkthrough. + +## Migrating from v0.0.101 + +The pre-1.0 SDK intentionally includes source-incompatible API corrections: + +- `TCP.Listen` returns a `ForwardListener` lifecycle handle. The SDK owns the + accept loop; callers dial `Addr()` and call `Close()` instead of calling + `Accept()` or passing the handle to `http.Serve`. +- Resource operations take an explicit workspace, and workspace-bearing domain + types preserve that scope. +- Several public struct field orders changed. Use keyed struct literals. +- Initialisms use Go spelling, including `JSONRPCMaxBodyBytes`. + +These changes are intentional while the module remains below v1. Update callers +as one migration rather than relying on the v0.0.101 API shape. + +### Inference Route Management + +Configure how inference requests are routed for a workspace: + +```go +// Set an inference route +route, err := client.Inference().SetRoute(ctx, "my-workspace", &v1.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "", // empty string = default route + TimeoutSecs: 120, +}) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Route v%d: %s/%s\n", route.Version, route.ProviderName, route.ModelID) + +// Retrieve the route +route, err = client.Inference().GetRoute(ctx, "my-workspace", "") +if err != nil { + log.Fatal(err) +} + +// Delete the route +err = client.Inference().DeleteRoute(ctx, "my-workspace", "") +if err != nil { + log.Fatal(err) +} +``` + +## Architecture + +``` +Client + ├── Sandboxes() → SandboxInterface (create, get, list, delete, watch, wait, logs) + ├── Exec() → ExecInterface (run, stream, interactive) + ├── Files() → FileInterface (upload, download) + ├── Health() → HealthInterface (health check, gateway info, current user) + ├── Services() → ServiceInterface (expose, get, list, delete) + ├── Providers() → ProviderInterface (CRUD + ensure) + │ ├── Profiles() → ProfileInterface (list, get, import, update, lint, delete) + │ └── Refresh() → RefreshInterface (configure, status, rotate, delete) + ├── Workspaces() → WorkspaceInterface (create, get, list, delete, members) + ├── Inference() → InferenceInterface (set, get, delete inference routes) + └── Policy() → PolicyInterface (draft review, approve, reject, merge, status) +``` + +All domain types live in `openshell/v1/types/`. Proto-to-Go conversions happen in +an internal converter layer. The public API surface uses type aliases so +consumers import a single package. See the [Architecture](https://ro14nd.de/openshell-sdk-go/architecture.html) overview for details. + +## Features + +| Feature | Interface | Docs | +|---------|-----------|------| +| Sandbox lifecycle (create, get, list, delete, watch, wait) | `SandboxInterface` | [Sandboxes](https://ro14nd.de/openshell-sdk-go/api/sandboxes.html) | +| Command execution (collected, streamed, interactive PTY) | `ExecInterface` | [Exec](https://ro14nd.de/openshell-sdk-go/api/exec.html) | +| Provider management (CRUD + idempotent ensure) | `ProviderInterface` | [Providers](https://ro14nd.de/openshell-sdk-go/api/providers.html) | +| Provider profiles (list, import, lint, update) | `ProfileInterface` | [Profiles](https://ro14nd.de/openshell-sdk-go/api/profiles.html) | +| Credential refresh (configure, rotate, status) | `RefreshInterface` | [Refresh](https://ro14nd.de/openshell-sdk-go/api/refresh.html) | +| Service exposure (expose, list, delete) | `ServiceInterface` | [Services](https://ro14nd.de/openshell-sdk-go/api/services.html) | +| File transfer API (transport capability-gated) | `FileInterface` | [Files](https://ro14nd.de/openshell-sdk-go/api/files.html) | +| Policy management (draft review, approve, reject, merge, global policy) | `PolicyInterface` | [Policy](https://ro14nd.de/openshell-sdk-go/api/policy.html) | +| Sandbox logs (streaming retrieval) | `SandboxInterface` | [Sandboxes](https://ro14nd.de/openshell-sdk-go/api/sandboxes.html) | +| Workspace management (create, get, list, delete, members) | `WorkspaceInterface` | [Workspaces](https://ro14nd.de/openshell-sdk-go/api/workspaces.html) | +| Inference route management (set, get, delete) | `InferenceInterface` | [Inference](https://ro14nd.de/openshell-sdk-go/api/inference.html) | +| Gateway info and current user identity | `HealthInterface` | [Health](https://ro14nd.de/openshell-sdk-go/api/health.html) | +| Health checking | `HealthInterface` | [Health](https://ro14nd.de/openshell-sdk-go/api/health.html) | +| SSH tunneling and TCP forwarding | `SSHInterface`, `TCPInterface` | [SSH](https://ro14nd.de/openshell-sdk-go/api/ssh.html), [TCP](https://ro14nd.de/openshell-sdk-go/api/tcp.html) | +| Auth: static token, refreshable token (oauth2.TokenSource) | `AuthProvider` | [Auth](https://ro14nd.de/openshell-sdk-go/api/auth.html) | +| Edge auth: extra headers, Cloudflare Access, WebSocket tunnel | `AuthProvider`, `edge.TunnelProxy` | [Edge](https://ro14nd.de/openshell-sdk-go/api/edge.html) | +| Typed errors (`IsNotFound`, `IsAlreadyExists`, `IsConflict`, ...) | `StatusError` | [Error Handling](https://ro14nd.de/openshell-sdk-go/error-handling.html) | +| Real-time watch with auto-stop on terminal phase | `WatchInterface[T]` | [Sandboxes](https://ro14nd.de/openshell-sdk-go/api/sandboxes.html) | +| Fake client for testing (no gRPC server needed) | `fake.Client` | [Testing](https://ro14nd.de/openshell-sdk-go/testing.html) | +| OIDC login (browser, keyboard, device code, client credentials) | `oidc.Login`, `oidc.DeviceLogin`, `oidc.ClientCredentials` | [OIDC](https://pkg.go.dev/github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc) | +| Gateway config convenience (load CLI gateway configs, auto-wire auth) | `gateway.NewClient`, `gateway.LoadConfig` | [Gateway](https://ro14nd.de/openshell-sdk-go/api/gateway.html) | + +## Prerequisites + +- Go 1.25 or later +- [mise](https://mise.jdx.dev) (recommended for reproducible builds) + +## Build and Test + +```bash +git clone https://github.com/NVIDIA/OpenShell.git +cd OpenShell/sdk/go + +mise run test # Run tests with coverage +mise run lint # Run golangci-lint +mise run ci # Full CI pipeline (lint + build + test) +``` + +Build commands use [mise](https://mise.jdx.dev) for reproducible tool management. + +## Documentation + +Full API documentation is available at the [OpenShell Go SDK Docs](https://ro14nd.de/openshell-sdk-go/) site. + +To build the docs locally: + +```bash +cargo install mdbook +mdbook serve docs +``` + +## License + +Apache-2.0. See [LICENSE](../../LICENSE) for details. + +Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. diff --git a/sdk/go/buf.gen.yaml b/sdk/go/buf.gen.yaml index 40e90d15df..626c71bb95 100644 --- a/sdk/go/buf.gen.yaml +++ b/sdk/go/buf.gen.yaml @@ -1,36 +1,35 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Code generation for the Go SDK. The proto module boundary and validation -# policy live in the repo-level buf.yaml; this template only drives generation. -# buf compiles the module with its own compiler and runs protoc-gen-go / -# protoc-gen-go-grpc from mise-managed binaries. Limited to the client-surface -# closure (openshell, datamodel, sandbox, options); well-known types resolve -# through google.golang.org/protobuf and are not generated. +# Code generation for the Go SDK. buf compiles the proto module with its own +# compiler and runs protoc-gen-go / protoc-gen-go-grpc from mise-managed +# binaries. The public SDK entry points are declared here; imported schemas +# are included automatically, so their canonical dependency closure is +# generated without duplicating a filename list in the generation scripts. version: v2 inputs: - - directory: ../../proto - paths: - - ../../proto/openshell.proto - - ../../proto/datamodel.proto - - ../../proto/sandbox.proto - - ../../proto/options.proto + - proto_file: proto/openshell.proto + - proto_file: proto/inference.proto plugins: - local: protoc-gen-go - out: . + out: sdk/go + include_imports: true opt: - module=github.com/NVIDIA/OpenShell/sdk/go - Mopenshell.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1 - Mdatamodel.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1 - Msandbox.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1 - Moptions.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1 + - Minference.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/inferencev1 - local: protoc-gen-go-grpc - out: . + out: sdk/go + include_imports: true opt: - module=github.com/NVIDIA/OpenShell/sdk/go - Mopenshell.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1 - Mdatamodel.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1 - Msandbox.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1 - Moptions.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1 + - Minference.proto=github.com/NVIDIA/OpenShell/sdk/go/proto/inferencev1 diff --git a/sdk/go/docs/book.toml b/sdk/go/docs/book.toml new file mode 100644 index 0000000000..01cfe47ab1 --- /dev/null +++ b/sdk/go/docs/book.toml @@ -0,0 +1,23 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[book] +title = "OpenShell Go SDK" +authors = ["NVIDIA Corporation"] +language = "en" +src = "src" + +[build] +build-dir = "book" +create-missing = false + +[output.html] +default-theme = "ayu" +preferred-dark-theme = "navy" +additional-css = ["theme/custom.css"] +git-repository-url = "https://github.com/NVIDIA/OpenShell/sdk/go" +edit-url-template = "https://github.com/NVIDIA/OpenShell/sdk/go/edit/main/docs/src/{path}" + +[output.html.search] +enable = true +limit-results = 20 diff --git a/sdk/go/docs/src/SUMMARY.md b/sdk/go/docs/src/SUMMARY.md new file mode 100644 index 0000000000..7a0915bfeb --- /dev/null +++ b/sdk/go/docs/src/SUMMARY.md @@ -0,0 +1,37 @@ +# Summary + +[Introduction](introduction.md) + +# Getting Started + +- [Quick Start](getting-started.md) + +# Architecture + +- [Overview](architecture.md) + +# API Reference + +- [Overview](api/overview.md) +- [Client](api/client.md) +- [Sandboxes](api/sandboxes.md) +- [Exec](api/exec.md) +- [Providers](api/providers.md) +- [Profiles](api/profiles.md) +- [Refresh](api/refresh.md) +- [Services](api/services.md) +- [Files](api/files.md) +- [Health](api/health.md) +- [SSH](api/ssh.md) +- [TCP](api/tcp.md) +- [Config](api/config.md) +- [Policy](api/policy.md) +- [Gateway](api/gateway.md) +- [OIDC](api/oidc.md) +- [Edge](api/edge.md) +- [Fake](api/fake.md) + +# Guides + +- [Error Handling](error-handling.md) +- [Testing](testing.md) diff --git a/sdk/go/docs/src/api/client.md b/sdk/go/docs/src/api/client.md new file mode 100644 index 0000000000..748de5327e --- /dev/null +++ b/sdk/go/docs/src/api/client.md @@ -0,0 +1,76 @@ +# Client + +Constructor: `v1.NewClient(config)` + +The `ClientInterface` is the root entry point for all SDK operations. It provides +typed accessors for each resource domain and manages the underlying gRPC connection. + +## Methods + +| Accessor | Returns | Description | +|----------|---------|-------------| +| `Sandboxes()` | `SandboxInterface` | Sandbox lifecycle management | +| `Providers()` | `ProviderInterface` | Provider CRUD and idempotent ensure | +| `Services()` | `ServiceInterface` | Service exposure and management | +| `Exec()` | `ExecInterface` | Command execution (run, stream, interactive) | +| `Files()` | `FileInterface` | File upload and download | +| `Health()` | `HealthInterface` | Gateway health checking | +| `SSH()` | `SSHInterface` | SSH session and tunnel management | +| `TCP()` | `TCPInterface` | TCP port forwarding | +| `Config()` | `ConfigInterface` | Sandbox and gateway configuration | +| `Policy()` | `PolicyInterface` | Draft policy review workflow | +| `Close()` | `error` | Close the gRPC connection | + +Sub-client hierarchy: `Providers()` has two nested accessors: +- `client.Providers().Profiles()` returns `ProfileInterface` +- `client.Providers().Refresh()` returns `RefreshInterface` + +## Creating a Client + +```go +import v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: v1.StaticToken("my-token"), +}) +if err != nil { + log.Fatal(err) +} +defer client.Close() +``` + +## Configuration + +The `Config` struct controls connection behavior: + +```go +type Config struct { + Address string // Gateway address (host:port) + TLS *TLSConfig // TLS settings (nil uses system defaults) + Auth AuthProvider // Authentication provider + Timeout time.Duration // Default timeout for all operations (0 = no timeout) + RetryPolicy *RetryPolicy // Retry configuration (nil = no automatic retries) + Logger Logger // Custom logger (nil = no logging) +} +``` + +Authentication providers: +- `v1.StaticToken(token)` provides a fixed bearer token +- `v1.NoAuth()` skips authentication (for local development) + +## Testing + +For unit tests, use the fake client instead of a real connection: + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" + +client := fake.NewClient() +defer client.Close() +``` + +The fake client implements the full `ClientInterface` with in-memory stores. +See [Testing](../testing.md) for details. + +See also: [Getting Started](../getting-started.md), [Architecture](../architecture.md) diff --git a/sdk/go/docs/src/api/config.md b/sdk/go/docs/src/api/config.md new file mode 100644 index 0000000000..601f1a86a7 --- /dev/null +++ b/sdk/go/docs/src/api/config.md @@ -0,0 +1,52 @@ +# Config + +Accessor: `client.Config()` + +Retrieve and update configuration for sandboxes and the gateway. + +## GetSandbox + +Retrieve the current configuration for a specific sandbox. + +```go +config, err := client.Config().GetSandbox(ctx, "default", "sandbox-123") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Sandbox config: policy_version=%d, revision=%d\n", + config.PolicyVersion, config.ConfigRevision) +``` + +## GetGateway + +Retrieve the gateway-level configuration. + +```go +config, err := client.Config().GetGateway(ctx) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Gateway settings revision: %d\n", config.SettingsRevision) +``` + +## Update + +Apply a configuration update. The update is validated before being applied. + +```go +result, err := client.Config().Update(ctx, "default", &v1.ConfigUpdate{ + Name: "sandbox-123", + SettingKey: "idle_timeout", + SettingValue: &v1.SettingValue{ + Type: v1.SettingValueString, + StringVal: "30m", + }, +}) +if err != nil { + // See [Error Handling](../error-handling.md) for validation errors + log.Fatal(err) +} +fmt.Printf("Config updated: revision=%d\n", result.SettingsRevision) +``` + +See also: [Error Handling](../error-handling.md) diff --git a/sdk/go/docs/src/api/edge.md b/sdk/go/docs/src/api/edge.md new file mode 100644 index 0000000000..eba531ad4d --- /dev/null +++ b/sdk/go/docs/src/api/edge.md @@ -0,0 +1,91 @@ +# Edge + +Package: `openshell/v1/edge` + +The edge package provides utilities for connecting to OpenShell gateways +through edge proxies such as Cloudflare Access. It includes auth wrappers +for edge proxy headers and a WebSocket tunnel proxy for gRPC transport +through HTTP/1.1-only proxies. + +## Cloudflare Access + +Wrap any `AuthProvider` with Cloudflare Access headers +(`cf-access-jwt-assertion` and `CF_Authorization` cookie): + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/edge" + +base := v1.StaticToken("my-gateway-token") +auth, err := edge.CloudflareAccess(base, os.Getenv("CF_ACCESS_TOKEN")) +if err != nil { + log.Fatal(err) +} +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: auth, +}) +``` + +CloudflareAccess composes with any auth provider, including `RefreshableToken` +for automatic token refresh: + +```go +tokenSource := oauth2Config.TokenSource(ctx, initialToken) +refreshAuth, err := v1.RefreshableToken(tokenSource) +if err != nil { + log.Fatal(err) +} +auth, err := edge.CloudflareAccess(refreshAuth, cfToken) +``` + +## WebSocket Tunnel + +`TunnelProxy` bridges gRPC connections over a WebSocket tunnel for edge +proxies that reject standard HTTP/2 POST requests. The tunnel carries +its own edge token for proxy authentication, independent of the +application-level auth provider. + +```go +tunnel, err := edge.NewTunnelProxy( + "wss://gateway.example.com/ws", + os.Getenv("CF_ACCESS_TOKEN"), +) +if err != nil { + log.Fatal(err) +} +defer tunnel.Close() + +auth := v1.StaticToken("my-gateway-token") +client, err := v1.NewClient(v1.Config{ + Address: tunnel.Addr(), + Auth: auth, + TLS: &v1.TLSConfig{Insecure: true}, // local tunnel +}) +``` + +## Functions + +| Function | Description | +|----------|-------------| +| `CloudflareAccess(base, edgeToken)` | Wrap an AuthProvider with Cloudflare Access headers | +| `NewTunnelProxy(url, edgeToken, opts...)` | Create a WebSocket tunnel proxy for gRPC-over-HTTP/1.1 | + +## TunnelProxy Methods + +| Method | Description | +|--------|-------------| +| `Addr()` | Local listener address for gRPC client to dial | +| `Close()` | Gracefully drain in-flight connections and shut down | + +## TunnelOption + +| Constructor | Effect | +|-------------|--------| +| `WithTunnelTLS(cfg)` | Configure TLS for the WebSocket connection | +| `WithTunnelLogger(l)` | Set a logger for tunnel events | +| `WithCloseTimeout(d)` | Override the graceful shutdown timeout (default 5s) | + +## Thread Safety + +All exported functions and methods are safe for concurrent use. +`Close` is idempotent and safe to call multiple times. diff --git a/sdk/go/docs/src/api/exec.md b/sdk/go/docs/src/api/exec.md new file mode 100644 index 0000000000..0d088a6999 --- /dev/null +++ b/sdk/go/docs/src/api/exec.md @@ -0,0 +1,161 @@ +# Exec + +Accessor: `client.Exec()` + +Execute commands in a sandbox with three modes: one-shot (`Run`), streaming +(`Stream`), or interactive terminal (`Interactive`). + +## Run + +Execute a command and collect all output into a single result. + +```go +result, err := client.Exec().Run(ctx, "default", "sbx-123", []string{"ls", "-la"}) +if err != nil { + log.Fatal(err) +} + +fmt.Println("Exit code:", result.ExitCode) +fmt.Println("Stdout:", result.Stdout) +fmt.Println("Stderr:", result.Stderr) +``` + +The SDK collects all streamed events, assembles stdout/stderr, and returns a single `ExecResult`. + +`ExecResult` contains the complete output after the command finishes: + +| Field | Type | Description | +|------------|--------|------------------------------| +| `Stdout` | string | Captured standard output | +| `Stderr` | string | Captured standard error | +| `ExitCode` | int | Process exit code | + +## Stream + +Execute a command and process output chunks as they arrive. + +```go +stream, err := client.Exec().Stream(ctx, "default", "sbx-123", []string{"tail", "-f", "/var/log/app.log"}) +if err != nil { + log.Fatal(err) +} +defer stream.Close() + +for { + chunk, err := stream.Next() + if err == io.EOF { + break + } + if err != nil { + log.Fatal(err) + } + + if chunk.Stream == v1.StreamStdout { + fmt.Print(string(chunk.Data)) + } +} + +exitCode, err := stream.ExitCode() +if err != nil { + log.Fatal(err) +} +fmt.Println("Exited with:", exitCode) +``` + +## Interactive + +Open a bidirectional terminal session with a command. + +```go +session, err := client.Exec().Interactive(ctx, "default", "sbx-123", []string{"/bin/bash"}, 80, 24) +if err != nil { + log.Fatal(err) +} +defer session.Close() + +// Send input +_, err = session.Write([]byte("echo hello\n")) +if err != nil { + log.Fatal(err) +} + +// Read output +buf := make([]byte, 4096) +n, err := session.Read(buf) +if err != nil { + log.Fatal(err) +} +fmt.Print(string(buf[:n])) + +// Handle terminal resize +if err := session.Resize(120, 40); err != nil { + log.Fatal(err) +} + +// Get exit code after session ends +exitCode, err := session.ExitCode() +if err != nil { + log.Fatal(err) +} +fmt.Println("Exited with:", exitCode) +``` + +The SDK wraps the bidirectional stream as an `InteractiveSession` with `Read`/`Write`/`Resize` methods. + +## ExecStream + +`ExecStream` provides an iterator interface over command output chunks. Call `Next()` repeatedly to receive output as it is produced. When the command finishes, `Next()` returns `io.EOF`. + +```go +type ExecStream interface { + Next() (*ExecChunk, error) + ExitCode() (int, error) + Close() error +} +``` + +| Method | Description | +|------------|----------------------------------------------------------------| +| `Next` | Returns the next output chunk. Returns `io.EOF` when done. | +| `ExitCode` | Returns the process exit code. Call after `Next` returns `io.EOF`. | +| `Close` | Releases the underlying stream resources. | + +## InteractiveSession + +`InteractiveSession` implements `io.Reader` and `io.Writer` for bidirectional communication with a running process. Use it for terminal emulation, REPL interaction, or any command that requires ongoing input. + +```go +type InteractiveSession interface { + Read(p []byte) (int, error) + Write(p []byte) (int, error) + Resize(cols, rows uint32) error + ExitCode() (int, error) + Close() error +} +``` + +| Method | Description | +|------------|---------------------------------------------------------------------| +| `Read` | Reads output from the process into the provided buffer. | +| `Write` | Sends input to the process. | +| `Resize` | Updates the terminal dimensions (columns and rows). | +| `ExitCode` | Returns the process exit code after the session ends. | +| `Close` | Closes the session and releases resources. | + +## ExecChunk + +Each chunk from `ExecStream.Next()` carries a segment of process output along with which stream it came from. + +```go +type ExecChunk struct { + Data []byte + Stream StreamType +} +``` + +| Field | Type | Description | +|----------|------------|---------------------------------------------------| +| `Data` | `[]byte` | Raw output bytes from the process. | +| `Stream` | StreamType | Either `StreamStdout` or `StreamStderr`. | + +See also: [Error Handling](../error-handling.md), [Testing](../testing.md) diff --git a/sdk/go/docs/src/api/fake.md b/sdk/go/docs/src/api/fake.md new file mode 100644 index 0000000000..084858c542 --- /dev/null +++ b/sdk/go/docs/src/api/fake.md @@ -0,0 +1,112 @@ +# Fake + +Package: `openshell/v1/fake` + +The fake package provides an in-memory fake implementation of all SDK +client interfaces for use in consumer test suites. It follows the +`client-go/kubernetes/fake` pattern: in-memory stores, watch event +broadcasting, and matching `StatusError` codes for equivalent error +conditions (`NotFound`, `AlreadyExists`, `Unavailable`, `Unimplemented`). + +## Quick Start + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" + +func TestSandboxLifecycle(t *testing.T) { + client := fake.NewClient() + defer client.Close() + + ctx := context.Background() + + sb, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) + require.NoError(t, err) + assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) + + sb, err = client.Sandboxes().WaitReady(ctx, "default", "my-sandbox") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) + + require.NoError(t, client.Sandboxes().Delete(ctx, "default", "my-sandbox")) +} +``` + +## Creating a Client + +```go +func NewClient(opts ...ClientOption) *Client +``` + +Returns a fake client implementing `v1.ClientInterface` with all +sub-clients wired up. Default health result is healthy. Use options +to customize initial state: + +```go +client := fake.NewClient( + fake.WithHealthResult(&types.HealthResult{Healthy: false}), + fake.WithCurrentUser(&types.CurrentUser{Subject: "test-user"}), + fake.WithGatewayInfo(&types.GatewayInfo{Version: "1.0.0"}), +) +``` + +## Pre-populating State + +Seed objects directly into the fake stores for test setup: + +```go +client := fake.NewClient() + +client.AddSandbox("default", &types.Sandbox{ + Name: "pre-existing", + Status: types.SandboxStatus{Phase: types.SandboxReady}, +}) + +client.AddProvider("default", &types.Provider{ + Name: "my-provider", + Spec: types.ProviderSpec{Type: "docker"}, +}) + +client.AddWorkspace(&types.Workspace{Name: "staging"}) +client.AddMember("staging", &types.WorkspaceMember{ + PrincipalSubject: "subject-123", + Role: types.WorkspaceRoleAdmin, +}) +``` + +All `Add*` methods deep-copy their arguments; mutating the input after +insertion does not affect the stored object. + +## Sub-Client Coverage + +The fake client implements every interface in `v1.ClientInterface`: + +| Accessor | Interface | Behavior | +|----------|-----------|----------| +| `Sandboxes()` | `SandboxInterface` | Full CRUD, Watch, WaitReady | +| `Providers()` | `ProviderInterface` | Full CRUD, Ensure | +| `Workspaces()` | `WorkspaceInterface` | Full CRUD, Members | +| `Health()` | `HealthInterface` | Configurable result | +| `Inference()` | `InferenceInterface` | Route CRUD | +| `Policy()` | `PolicyInterface` | List, GetStatus (draft ops return Unimplemented) | +| `Exec()` | `ExecInterface` | Returns Unimplemented | +| `Files()` | `FileInterface` | Returns Unimplemented | +| `Services()` | `ServiceInterface` | Returns Unimplemented | +| `SSH()` | `SSHInterface` | Input validation, then Unimplemented | +| `TCP()` | `TCPInterface` | Input validation, then Unimplemented | +| `Config()` | `ConfigInterface` | Returns Unimplemented | + +## ClientOption + +| Constructor | Effect | +|-------------|--------| +| `WithHealthResult(r)` | Set the health check return value | +| `WithCurrentUser(u)` | Set the current user return value | +| `WithGatewayInfo(i)` | Set the gateway info return value | + +## Thread Safety + +All operations are safe for concurrent use from multiple goroutines. +`Close` is idempotent and causes all subsequent operations to return +`Unavailable`. + +See also: [Testing Guide](../testing.md) diff --git a/sdk/go/docs/src/api/files.md b/sdk/go/docs/src/api/files.md new file mode 100644 index 0000000000..65026cf6a4 --- /dev/null +++ b/sdk/go/docs/src/api/files.md @@ -0,0 +1,36 @@ +# Files + +Accessor: `client.Files()` + +`FileInterface` reserves the upload and download API while keeping sandbox lookup +and SSH-session lifecycle behavior stable. The standalone SDK does not currently +ship an SSH file-transfer transport, so both operations return +`v1.ErrTransportNotAvailable` before performing local validation or gateway RPCs. + +## Upload + +Detect transport availability programmatically: + +```go +err := client.Files().Upload(ctx, "default", "sandbox-123", "./data/config.yaml", "/app/config.yaml") +if errors.Is(err, v1.ErrTransportNotAvailable) { + // Use another transfer mechanism until an SSH transport is available. +} else if err != nil { + log.Fatal(err) +} +``` + +## Download + +`Download` has the same capability gate: + +```go +err := client.Files().Download(ctx, "default", "sandbox-123", "/app/output.log", "./output.log") +if errors.Is(err, v1.ErrTransportNotAvailable) { + // Use another transfer mechanism until an SSH transport is available. +} else if err != nil { + log.Fatal(err) +} +``` + +See also: [Error Handling](../error-handling.md), [Testing](../testing.md) diff --git a/sdk/go/docs/src/api/gateway.md b/sdk/go/docs/src/api/gateway.md new file mode 100644 index 0000000000..f61e19a85d --- /dev/null +++ b/sdk/go/docs/src/api/gateway.md @@ -0,0 +1,186 @@ +# Gateway + +Package: `openshell/v1/gateway` + +The gateway package reads on-disk gateway configurations created by the +OpenShell Rust CLI and constructs fully wired SDK clients. It eliminates +the boilerplate of locating config files, parsing metadata, loading +tokens, and wiring auth providers. + +## Quick Start + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" + +// Connect to a named gateway +client, err := gateway.NewClient("prod") +if err != nil { + log.Fatal(err) +} +defer client.Close() +``` + +## Functions + +| Function | Description | +|----------|-------------| +| `NewClient(name, opts...)` | Create a fully wired SDK client from gateway config | +| `LoadConfig(name)` | Parse gateway config without connecting | +| `ListGateways()` | Enumerate all available gateways | + +### NewClient + +```go +func NewClient(name string, opts ...ClientOption) (*v1.Client, error) +``` + +Creates a fully configured SDK client for the named gateway. If `name` +is empty, the active gateway (set via `openshell gateway use`) is used. + +The function resolves the gateway directory, parses `metadata.json`, +loads tokens lazily, maps the auth mode to an SDK auth provider, and +applies any `ClientOption` values before delegating to `v1.NewClient`. + +```go +// Named gateway +client, err := gateway.NewClient("prod") + +// Active gateway +client, err := gateway.NewClient("") + +// With options +client, err := gateway.NewClient("staging", + gateway.WithTimeout(10 * time.Second), + gateway.WithLogger(myLogger), +) +``` + +**Errors**: `ErrGatewayNotFound`, `ErrConfigParse`, `ErrTokenLoad`, +`ErrUnsupportedAuthMode`, `ErrInvalidGatewayName`, `ErrNoActiveGateway` + +### LoadConfig + +```go +func LoadConfig(name string) (*Config, error) +``` + +Parses gateway configuration without creating a client connection. +Returns a frozen snapshot; changes to on-disk files after the call +are not reflected. + +```go +cfg, err := gateway.LoadConfig("staging") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Endpoint: %s, Auth: %s\n", cfg.Endpoint, cfg.AuthMode) +``` + +### ListGateways + +```go +func ListGateways() ([]Info, error) +``` + +Enumerates all available gateways from user and system directories. +User gateways appear first. Duplicate names resolve to user precedence. +Returns an empty slice (not an error) when no gateways are configured. + +```go +gateways, err := gateway.ListGateways() +for _, gw := range gateways { + fmt.Printf("%s (active=%v, source=%s)\n", gw.Name, gw.Active, gw.Source) +} +``` + +## Types + +### Config + +```go +type Config struct { + Name string // Validated gateway name + Endpoint string // Host:port of the gateway + AuthMode AuthMode // Resolved auth mode + Source ConfigSource // User or System origin + Dir string // Absolute path to gateway config directory +} +``` + +### Info + +```go +type Info struct { + Name string // Gateway name from directory listing + Active bool // Whether this is the active gateway + Source ConfigSource // User or System origin +} +``` + +### AuthMode + +| Value | Constant | SDK AuthProvider | +|-------|----------|-----------------| +| `""` or `"none"` | `AuthModeNone` | `v1.NoAuth()` | +| `"plaintext"` | `AuthModePlaintext` | `v1.NoAuth()` + insecure TLS | +| `"cloudflare_jwt"` | `AuthModeCloudflareJWT` | Lazy edge token auth | +| `"oidc"` | `AuthModeOIDC` | `v1.RefreshableToken` with disk source | +| `"mtls"` | `AuthModeMTLS` | Unsupported (use `WithAuth`) | + +### ClientOption + +| Constructor | Effect | +|-------------|--------| +| `WithLogger(l)` | Set logger on the SDK client | +| `WithTimeout(d)` | Set connection timeout | +| `WithTLS(cfg)` | Override TLS settings from gateway config | +| `WithAuth(provider)` | Override auto-resolved auth provider | +| `WithRetryPolicy(p)` | Set retry policy | + +## Error Handling + +All errors support `errors.Is` for classification: + +```go +client, err := gateway.NewClient("my-gateway") +if errors.Is(err, gateway.ErrGatewayNotFound) { + fmt.Println("Gateway not configured. Run: openshell gateway add my-gateway") +} +if errors.Is(err, gateway.ErrTokenLoad) { + fmt.Println("Token expired or missing. Run: openshell gateway login my-gateway") +} +``` + +| Error | Meaning | +|-------|---------| +| `ErrGatewayNotFound` | No gateway directory in user or system paths | +| `ErrConfigParse` | metadata.json missing or malformed | +| `ErrTokenLoad` | Token file missing or unreadable | +| `ErrUnsupportedAuthMode` | Unrecognized auth_mode value | +| `ErrInvalidGatewayName` | Name fails validation | +| `ErrNoActiveGateway` | No active gateway configured | + +## On-Disk Layout + +The package reads gateway metadata from these locations: + +``` +$XDG_CONFIG_HOME/openshell/ (user, default: ~/.config/openshell/) +├── active_gateway # Plain text: active gateway name +└── gateways/ + └── / + ├── metadata.json # {"endpoint":"...","auth_mode":"...","name":"..."} + ├── edge_token # Cloudflare edge JWT (plaintext) + ├── cf_token # Legacy edge token (fallback) + └── oidc_token.json # OIDC token bundle + +/etc/openshell/gateways/ (system, fallback) +``` + +User gateways take precedence over system gateways with the same name. + +## Thread Safety + +All exported functions (`NewClient`, `LoadConfig`, `ListGateways`) are +safe for concurrent use from multiple goroutines. Token loading uses +internal synchronization. diff --git a/sdk/go/docs/src/api/health.md b/sdk/go/docs/src/api/health.md new file mode 100644 index 0000000000..397a2b5d7f --- /dev/null +++ b/sdk/go/docs/src/api/health.md @@ -0,0 +1,32 @@ +# Health + +Accessor: `client.Health()` + +Check the health status of the connected OpenShell gateway. + +## Check + +Perform a health check against the gateway. Returns the overall status and +component-level details. + +```go +result, err := client.Health().Check(ctx) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Gateway healthy: %v\n", result.Healthy) +``` + +This is useful for readiness probes or verifying connectivity before executing +other operations. + +```go +// Quick connectivity check before starting work +if result, err := client.Health().Check(ctx); err != nil { + log.Fatalf("Gateway unreachable: %v", err) +} else if !result.Healthy { + log.Fatal("Gateway is not healthy") +} +``` + +See also: [Error Handling](../error-handling.md) diff --git a/sdk/go/docs/src/api/oidc.md b/sdk/go/docs/src/api/oidc.md new file mode 100644 index 0000000000..ae30f35a1c --- /dev/null +++ b/sdk/go/docs/src/api/oidc.md @@ -0,0 +1,157 @@ +# OIDC Login + +Package: `openshell/v1/oidc` + +The oidc package provides OIDC authentication for OpenShell gateways. +It supports four OAuth2 flows: browser-based authorization code with +PKCE, keyboard fallback, device code (RFC 8628), and client credentials. + +## Quick Start + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc" + +// Gateway-aware login (reads OIDC config from gateway metadata) +token, err := oidc.Login(ctx, "my-gateway") +if err != nil { + log.Fatal(err) +} +``` + +## Functions + +| Function | Description | +|----------|-------------| +| `Login(ctx, gatewayName, opts...)` | Interactive login via browser or keyboard flow | +| `DeviceLogin(ctx, opts...)` | Device authorization grant (RFC 8628) | +| `ClientCredentials(ctx, opts...)` | Non-interactive client credentials grant | + +### Login + +```go +func Login(ctx context.Context, gatewayName string, opts ...LoginOption) (*oauth2.Token, error) +``` + +Performs an interactive OIDC login. When `gatewayName` is provided, the +OIDC issuer and client ID are read from the gateway's `metadata.json`. +The default flow opens a browser for authorization code exchange with +PKCE. If the provider does not support PKCE (S256), the flow proceeds +without it. + +Tokens are persisted to the gateway's config directory as +`oidc_token.json`. Subsequent calls reuse a valid cached token. + +For standalone use (no gateway), pass an empty `gatewayName` with +`WithIssuer` and `WithClientID`. Combine with `WithInMemory` to skip +disk persistence. + +### DeviceLogin + +```go +func DeviceLogin(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) +``` + +Performs an OAuth2 device authorization grant (RFC 8628). The flow +requests a device code and user code from the provider, displays them +via `WithDisplayFunc` (or stdout), and polls the token endpoint until +the user completes authorization. + +Requires `WithIssuer` and `WithClientID`, or `WithGateway`. + +### ClientCredentials + +```go +func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) +``` + +Performs a non-interactive OAuth2 client credentials grant. Requires +`WithIssuer`, `WithClientID`, and `WithClientSecret` (or `WithGateway` +combined with `WithClientSecret`). The client secret is never included +in error messages. + +## Options + +| Option | Description | +|--------|-------------| +| `WithIssuer(url)` | OIDC provider issuer URL | +| `WithClientID(id)` | OAuth2 client ID | +| `WithClientSecret(secret)` | OAuth2 client secret (for client credentials) | +| `WithScopes(scopes...)` | Custom scopes (default: openid, profile, email) | +| `WithCallbackPort(port)` | Fixed port for localhost callback (default: tries 8000, then 18000) | +| `WithTimeout(d)` | Auth flow timeout (default: 2 minutes) | +| `WithKeyboardFlow()` | Use keyboard flow instead of browser | +| `WithInMemory()` | Skip token persistence to disk | +| `WithDisplayFunc(fn)` | Custom display for device code flow | +| `WithGateway(name)` | Resolve OIDC config from gateway metadata | + +## Error Handling + +The package defines sentinel errors for `errors.Is()` matching: + +| Error | Description | +|-------|-------------| +| `ErrDiscovery` | OIDC discovery document fetch failed | +| `ErrAuthCode` | Authorization code exchange failed | +| `ErrDeviceCode` | Device code flow failed | +| `ErrClientCredentials` | Client credentials exchange failed | +| `ErrTimeout` | Authentication flow timed out | +| `ErrCallbackServer` | Localhost callback server failed | +| `ErrTokenPersist` | Token read/write failed | +| `ErrOIDCConfig` | Missing or invalid OIDC configuration | + +```go +token, err := oidc.Login(ctx, "my-gateway") +if errors.Is(err, oidc.ErrDiscovery) { + // Provider unreachable +} +if errors.Is(err, oidc.ErrTimeout) { + // User did not complete login in time +} +``` + +## Gateway Integration + +When a gateway's `metadata.json` contains `oidc_issuer` and +`oidc_client_id` fields, `Login` and `DeviceLogin` can resolve +configuration automatically: + +```go +// Login reads OIDC config from the gateway +token, err := oidc.Login(ctx, "my-gateway") + +// Then use the token with the SDK client +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: v1.StaticToken(token.AccessToken), +}) +``` + +## Flows + +### Browser Flow (default) + +1. Discovers OIDC endpoints from issuer +2. Generates PKCE code verifier and S256 challenge +3. Opens browser to authorization URL +4. Starts localhost callback server to receive the auth code +5. Exchanges code for tokens with PKCE verification +6. Persists tokens to disk + +### Keyboard Flow + +Same as browser flow, but prints the authorization URL for the user to +copy manually. The user pastes the authorization code back into the +terminal. Use `WithKeyboardFlow()` to enable. + +### Device Code Flow (RFC 8628) + +1. Requests a device code and user code from the provider +2. Displays the verification URL and user code +3. Polls the token endpoint at the provider's requested interval +4. Handles `slow_down` responses by increasing the poll interval + +### Client Credentials Flow + +1. Discovers OIDC endpoints from issuer +2. Exchanges client ID and secret for an access token +3. No user interaction required diff --git a/sdk/go/docs/src/api/overview.md b/sdk/go/docs/src/api/overview.md new file mode 100644 index 0000000000..d96bbd747a --- /dev/null +++ b/sdk/go/docs/src/api/overview.md @@ -0,0 +1,61 @@ +# API Overview + +The OpenShell Go SDK exposes 12 interfaces through the sub-client pattern. You access each interface through a typed accessor on the `Client`. + +## Interface Summary + +### Top-Level Interfaces + +| Interface | Accessor | Description | +|-----------|----------|-------------| +| [SandboxInterface](sandboxes.md) | `client.Sandboxes()` | Create, manage, and watch sandbox lifecycle | +| [ExecInterface](exec.md) | `client.Exec()` | Run commands, stream output, interactive sessions | +| [ProviderInterface](providers.md) | `client.Providers()` | Manage compute providers and their lifecycle | +| [ServiceInterface](services.md) | `client.Services()` | Expose and manage HTTP services inside sandboxes | +| [FileInterface](files.md) | `client.Files()` | Upload and download files to/from sandboxes | +| [HealthInterface](health.md) | `client.Health()` | Check gateway health status | +| [SSHInterface](ssh.md) | `client.SSH()` | Create SSH sessions and tunnels to sandboxes | +| [TCPInterface](tcp.md) | `client.TCP()` | Forward TCP connections to sandbox ports | +| [ConfigInterface](config.md) | `client.Config()` | Read and update sandbox and gateway configuration | +| [PolicyInterface](policy.md) | `client.Policy()` | Manage draft policy recommendations | + +### Convenience Packages + +| Package | Entry Point | Description | +|---------|-------------|-------------| +| [gateway](gateway.md) | `gateway.NewClient(name)` | Read CLI gateway configs and auto-wire clients | + +### Provider Sub-Interfaces + +These are accessed through `client.Providers()`: + +| Interface | Accessor | Description | +|-----------|----------|-------------| +| [ProfileInterface](profiles.md) | `client.Providers().Profiles()` | Manage provider type profiles | +| [RefreshInterface](refresh.md) | `client.Providers().Refresh()` | Configure credential refresh strategies | + +## Interfaces + +Each interface has a reference page with method signatures and usage examples: + +- **[Sandboxes](sandboxes.md)**: Create sandboxes, wait for readiness, watch state changes, manage providers, retrieve logs. +- **[Exec](exec.md)**: Execute commands with one-shot, streaming, or interactive modes. +- **[Providers](providers.md)**: Register and manage compute providers. Includes sub-clients for profiles and credential refresh. +- **[Services](services.md)**: Expose and manage HTTP services inside sandboxes. +- **[Files](files.md)**: Upload and download files to/from sandboxes. +- **[Health](health.md)**: Check gateway health status. +- **[SSH](ssh.md)**: Create SSH sessions and tunnels to sandboxes. +- **[TCP](tcp.md)**: Forward TCP connections to sandbox ports. +- **[Config](config.md)**: Read and update sandbox and gateway configuration. +- **[Policy](policy.md)**: Manage draft policy recommendations. +- **[Profiles](profiles.md)**: Manage provider type profiles (via `client.Providers().Profiles()`). +- **[Refresh](refresh.md)**: Configure credential refresh strategies (via `client.Providers().Refresh()`). + +## Common Patterns + +All SDK methods follow these conventions: + +- Every method takes `context.Context` as its first argument +- Methods that can fail return `(result, error)` +- List methods accept variadic option arguments +- Errors from the gateway carry a `StatusError` with a typed code (see [Error Handling](../error-handling.md)) diff --git a/sdk/go/docs/src/api/policy.md b/sdk/go/docs/src/api/policy.md new file mode 100644 index 0000000000..62f475cd68 --- /dev/null +++ b/sdk/go/docs/src/api/policy.md @@ -0,0 +1,93 @@ +# Policy + +Accessor: `client.Policy()` + +Manage network policies for sandboxes through a draft-based workflow. Policies +go through a draft, review, and approval cycle before being applied. + +## GetDraft + +Retrieve the current draft policy for a sandbox. + +```go +draft, err := client.Policy().GetDraft(ctx, "default", "my-sandbox") +if err != nil { + log.Fatal(err) +} +for _, chunk := range draft.Chunks { + fmt.Printf("Chunk %s: rule=%s, status=%s, confidence=%.1f\n", + chunk.ID, chunk.RuleName, chunk.Status, chunk.Confidence) +} +``` + +## ApproveAllDraftChunks + +Approve all pending chunks in a single operation. + +```go +result, err := client.Policy().ApproveAllDraftChunks(ctx, "default", "my-sandbox") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Approved %d chunks (skipped %d), policy version: %d\n", + result.ChunksApproved, result.ChunksSkipped, result.PolicyVersion) +``` + +## GetStatus + +Check the current policy enforcement status for a sandbox. + +```go +status, err := client.Policy().GetStatus(ctx, "default", "my-sandbox") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Active version: %d, revision status: %s\n", + status.ActiveVersion, status.Revision.Status) +``` + +## List + +List all policy revisions for a sandbox. + +```go +revisions, err := client.Policy().List(ctx, "default", "my-sandbox") +if err != nil { + log.Fatal(err) +} +for _, rev := range revisions { + fmt.Printf("Version %d: %s (status: %s)\n", + rev.Version, rev.CreatedAt, rev.Status) +} +``` + +## RejectDraftChunk + +Reject a specific draft chunk, providing a reason. + +```go +err := client.Policy().RejectDraftChunk(ctx, "default", "my-sandbox", "chunk-abc", "Too permissive") +if err != nil { + log.Fatal(err) +} +``` + +## EditDraftChunk + +Modify the proposed rule in a draft chunk before approval. + +```go +err := client.Policy().EditDraftChunk(ctx, "default", "my-sandbox", "chunk-abc", &v1.NetworkPolicyRule{ + Name: "allow-api", + Endpoints: []v1.PolicyNetworkEndpoint{{ + Host: "api.example.com", + Port: 443, + Protocol: "tcp", + }}, +}) +if err != nil { + log.Fatal(err) +} +``` + +See also: [Error Handling](../error-handling.md), [Testing](../testing.md) diff --git a/sdk/go/docs/src/api/profiles.md b/sdk/go/docs/src/api/profiles.md new file mode 100644 index 0000000000..0cd91d5742 --- /dev/null +++ b/sdk/go/docs/src/api/profiles.md @@ -0,0 +1,85 @@ +# Profiles + +Accessor: `client.Providers().Profiles()` + +Manage provider profiles for AI model providers. Profiles define connection details, +credentials, and model mappings for providers like OpenAI, Anthropic, or custom endpoints. + +## List + +List all provider profiles visible to the current user. + +```go +profiles, err := client.Providers().Profiles().List(ctx, "default") +if err != nil { + log.Fatal(err) +} +for _, p := range profiles { + fmt.Printf("Profile: %s (%s)\n", p.ID, p.DisplayName) +} +``` + +## Import + +Import one or more provider profiles from configuration items. + +```go +result, err := client.Providers().Profiles().Import(ctx, "default", []v1.ProfileImportItem{ + { + Profile: v1.ProviderProfile{ + DisplayName: "OpenAI", + Category: v1.ProfileCategoryInference, + }, + Source: "manual", + }, +}) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Imported: %v\n", result.Imported) +``` + +## Update + +Update a profile using optimistic concurrency control via the resource version. + +```go +profile, err := client.Providers().Profiles().Get(ctx, "default", "profile-id") +if err != nil { + log.Fatal(err) +} + +profile.DisplayName = "Updated Provider" +result, err := client.Providers().Profiles().Update( + ctx, "default", + profile.ID, + profile.ResourceVersion, + v1.ProfileImportItem{Profile: *profile, Source: "manual"}, +) +if err != nil { + // See [Error Handling](../error-handling.md) for conflict errors + log.Fatal(err) +} +``` + +## Lint + +Validate profile configurations without persisting them. Useful for pre-flight checks. + +```go +items := []v1.ProfileImportItem{ + { + Profile: v1.ProviderProfile{DisplayName: "Test"}, + Source: "manual", + }, +} +result, err := client.Providers().Profiles().Lint(ctx, "default", items) +if err != nil { + log.Fatal(err) +} +for _, d := range result.Diagnostics { + fmt.Printf("[%s] %s: %s\n", d.Severity, d.Field, d.Message) +} +``` + +See also: [Error Handling](../error-handling.md), [Testing](../testing.md) diff --git a/sdk/go/docs/src/api/providers.md b/sdk/go/docs/src/api/providers.md new file mode 100644 index 0000000000..9eec23f675 --- /dev/null +++ b/sdk/go/docs/src/api/providers.md @@ -0,0 +1,151 @@ +# Providers + +Accessor: `client.Providers()` + +Register and manage compute providers (AI inference endpoints). Exposes +sub-clients for [Profiles](profiles.md) and [Refresh](refresh.md). + +## Create + +Register a new provider with the gateway. + +```go +provider, err := client.Providers().Create(ctx, "default", &v1.Provider{ + Name: "my-openai", + Type: "openai", + Spec: v1.ProviderSpec{ + Credentials: map[string]string{ + "api_key": "sk-...", + }, + Config: map[string]string{ + "base_url": "https://api.openai.com/v1", + }, + }, +}) +if err != nil { + log.Fatal(err) +} +fmt.Println("Created provider:", provider.Name) +``` + +## Get + +Fetch a provider by name. + +```go +provider, err := client.Providers().Get(ctx, "default", "my-openai") +if err != nil { + log.Fatal(err) +} +fmt.Println("Provider type:", provider.Type) +``` + +## List + +List all registered providers, with optional pagination. + +```go +// List all providers +providers, err := client.Providers().List(ctx, "default") +if err != nil { + log.Fatal(err) +} +for _, p := range providers { + fmt.Println(p.Name, p.Type) +} + +// With pagination +providers, err = client.Providers().List(ctx, "default", v1.ListOptions{ + Limit: 10, + Offset: 0, +}) +``` + +## Update + +Update an existing provider's configuration or credentials. + +```go +provider, err := client.Providers().Get(ctx, "default", "my-openai") +if err != nil { + log.Fatal(err) +} + +provider.Spec.Credentials["api_key"] = "sk-new-key" +updated, err := client.Providers().Update(ctx, "default", provider) +if err != nil { + log.Fatal(err) +} +fmt.Println("Updated provider:", updated.Name) +``` + +## Delete + +Remove a provider by name. + +```go +err := client.Providers().Delete(ctx, "default", "my-openai") +if err != nil { + log.Fatal(err) +} +``` + +## Ensure + +Create or update a provider in a single idempotent call. If a provider with the given name exists, it is updated; otherwise a new one is created. This is the recommended way to register providers because it avoids "already exists" errors when re-registering. + +```go +provider, err := client.Providers().Ensure(ctx, "default", &v1.Provider{ + Name: "my-openai", + Type: "openai", + Spec: v1.ProviderSpec{ + Credentials: map[string]string{ + "api_key": "sk-...", + }, + }, +}) +if err != nil { + log.Fatal(err) +} +fmt.Println("Provider ready:", provider.Name) +``` + +Ensure is an SDK-level convenience. It calls `GetProvider` first, then either `CreateProvider` or `UpdateProvider` depending on whether the provider already exists. + +## Sub-Clients + +The `ProviderInterface` exposes two sub-client accessors for related operations. These are pure client-side accessors with no corresponding gRPC call. + +### Profiles + +`client.Providers().Profiles()` returns a [ProfileInterface](profiles.md) for managing provider type profiles. Profiles define templates and defaults for different provider types. + +### Refresh + +`client.Providers().Refresh()` returns a [RefreshInterface](refresh.md) for configuring credential refresh strategies. Use it to set up automatic credential rotation for providers with expiring credentials. + +## Provider + +The `Provider` type represents a registered compute provider. + +| Field | Type | Description | +|-------------------|------------------------|--------------------------------------------| +| `ID` | string | Server-assigned unique identifier | +| `Name` | string | User-chosen name (unique per gateway) | +| `Type` | string | Provider type (e.g., `"openai"`, `"azure"`) | +| `CreatedAt` | time.Time | Timestamp of creation | +| `Labels` | map[string]string | Key-value metadata labels | +| `ResourceVersion` | uint64 | Optimistic concurrency version | +| `Spec` | ProviderSpec | Configuration and credentials | + +## ProviderSpec + +`ProviderSpec` holds provider-specific configuration and credentials. + +| Field | Type | Description | +|------------------------|---------------------------|-------------------------------------------------| +| `Credentials` | map[string]string | Authentication credentials (e.g., API keys) | +| `Config` | map[string]string | Provider-specific configuration values | +| `CredentialExpiresAt` | map[string]time.Time | Expiration timestamps for credentials | + +See also: [Profiles](profiles.md), [Refresh](refresh.md), [Error Handling](../error-handling.md), [Testing](../testing.md) diff --git a/sdk/go/docs/src/api/refresh.md b/sdk/go/docs/src/api/refresh.md new file mode 100644 index 0000000000..d738ae059e --- /dev/null +++ b/sdk/go/docs/src/api/refresh.md @@ -0,0 +1,57 @@ +# Refresh + +Accessor: `client.Providers().Refresh()` + +Manage credential refresh schedules for provider profiles. Configure automatic +rotation of API keys and monitor refresh status. + +## GetStatus + +Check the refresh status for a specific provider credential. + +```go +statuses, err := client.Providers().Refresh().GetStatus(ctx, "default", "openai", "default") +if err != nil { + log.Fatal(err) +} +for _, s := range statuses { + fmt.Printf("Key: %s, Last refresh: %s, Next: %s\n", + s.CredentialKey, s.LastRefreshAt, s.NextRefreshAt) +} +``` + +## Configure + +Set up automatic credential refresh with a defined strategy and material. + +```go +status, err := client.Providers().Refresh().Configure(ctx, "default", &v1.RefreshConfig{ + Provider: "openai", + CredentialKey: "default", + Strategy: v1.RefreshStrategyOAuth2ClientCredentials, + Material: map[string]string{ + "client_id": "my-client-id", + "client_secret": "my-client-secret", + "token_url": "https://oauth.example.com/token", + }, + SecretMaterialKeys: []string{"client_secret"}, +}) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Refresh configured, next rotation: %s\n", status.NextRefreshAt) +``` + +## Rotate + +Manually trigger an immediate credential rotation. + +```go +status, err := client.Providers().Refresh().Rotate(ctx, "default", "openai", "default") +if err != nil { + log.Fatal(err) +} +fmt.Printf("Rotated successfully at %s\n", status.LastRefreshAt) +``` + +See also: [Error Handling](../error-handling.md), [Profiles](profiles.md) diff --git a/sdk/go/docs/src/api/sandboxes.md b/sdk/go/docs/src/api/sandboxes.md new file mode 100644 index 0000000000..a9db856786 --- /dev/null +++ b/sdk/go/docs/src/api/sandboxes.md @@ -0,0 +1,210 @@ +# Sandboxes + +Accessor: `client.Sandboxes()` + +Manage sandbox lifecycle: create, inspect, delete, attach/detach providers, +wait for readiness, watch state changes, and retrieve logs. + +## Create + +Creates a new sandbox with the given name, spec, and labels. + +```go +sb, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{ + Template: &v1.SandboxTemplate{ + Image: "nvcr.io/nvidia/openshell:latest", + }, + Providers: []string{"openai"}, +}, map[string]string{ + "team": "platform", +}) +``` + +## Get + +Retrieves a sandbox by name. + +```go +sb, err := client.Sandboxes().Get(ctx, "default", "my-sandbox") +fmt.Println(sb.Status.Phase) // "Ready", "Provisioning", etc. +``` + +## List + +Lists sandboxes with optional pagination and label filtering. + +```go +// List all sandboxes +sandboxes, err := client.Sandboxes().List(ctx, "default") + +// With pagination and label filtering +sandboxes, err := client.Sandboxes().List(ctx, "default", v1.ListOptions{ + Limit: 10, + Offset: 0, + LabelSelector: "team=platform", +}) +``` + +## Delete + +Deletes a sandbox by name. + +```go +err := client.Sandboxes().Delete(ctx, "default", "my-sandbox") +``` + +## AttachProvider + +Attaches a provider to a sandbox. The `expectedResourceVersion` enables optimistic concurrency control: pass the sandbox's current `ResourceVersion` to ensure no other client has modified it since your last read. + +```go +sb, _ := client.Sandboxes().Get(ctx, "default", "my-sandbox") + +result, err := client.Sandboxes().AttachProvider(ctx, + "default", "my-sandbox", + "openai", + sb.ResourceVersion, +) +fmt.Println(result.Attached) // true if newly attached +``` + +## DetachProvider + +Detaches a provider from a sandbox. Uses the same optimistic concurrency pattern as `AttachProvider`. + +```go +sb, _ := client.Sandboxes().Get(ctx, "default", "my-sandbox") + +result, err := client.Sandboxes().DetachProvider(ctx, + "default", "my-sandbox", + "openai", + sb.ResourceVersion, +) +fmt.Println(result.Detached) // true if actually detached +``` + +## ListProviders + +Lists all providers currently attached to a sandbox. + +```go +providers, err := client.Sandboxes().ListProviders(ctx, "default", "my-sandbox") +for _, p := range providers { + fmt.Printf("provider: %s (type: %s)\n", p.Name, p.Type) +} +``` + +## WaitReady + +Blocks until the sandbox reaches the `Ready` phase, returning the final sandbox state. Under the hood, WaitReady polls via `Get` at a configurable interval (default 500ms). Use context cancellation or deadlines to set a timeout. + +If the sandbox enters the `Error` phase, WaitReady returns immediately with a `StatusError`. + +```go +// Wait with a 30-second timeout +ctx, cancel := context.WithTimeout(ctx, 30*time.Second) +defer cancel() + +sb, err := client.Sandboxes().WaitReady(ctx, "default", "my-sandbox") +if err != nil { + log.Fatal(err) +} +fmt.Println(sb.Status.Phase) // "Ready" + +// Custom poll interval +sb, err := client.Sandboxes().WaitReady(ctx, "default", "my-sandbox", v1.WaitOptions{ + PollInterval: 2 * time.Second, +}) +``` + +There is no dedicated WaitReady RPC. The SDK implements this by polling `GetSandbox` until the sandbox phase is `Ready` or `Error`. + +## Watch + +Opens a server-streaming connection to observe sandbox state changes in real time. Returns a `WatchInterface[*Sandbox]` that delivers events through a channel. + +The `WatchInterface[T]` provides: + +- `ResultChan() <-chan Event[T]` returns the channel of events +- `Stop()` closes the stream and the channel + +Each `Event[T]` carries: + +- `Type`: one of `EventAdded`, `EventModified`, `EventDeleted`, or `EventError` +- `Object`: the `*Sandbox` at that point in time (`nil` for `EventError`) + +```go +watcher, err := client.Sandboxes().Watch(ctx, "default", "my-sandbox") +if err != nil { + log.Fatal(err) +} +defer watcher.Stop() + +for event := range watcher.ResultChan() { + switch event.Type { + case v1.EventModified: + fmt.Printf("phase: %s\n", event.Object.Status.Phase) + case v1.EventDeleted: + fmt.Println("sandbox deleted") + return + case v1.EventError: + fmt.Println("watch error") + return + } +} +``` + +Setting `StopOnTerminal: true` causes the watcher to close automatically once the sandbox reaches a terminal phase (`Ready` or `Error`). This is useful for provisioning flows where you only care about the outcome. + +```go +watcher, err := client.Sandboxes().Watch(ctx, "default", "my-sandbox", v1.WatchOptions{ + StopOnTerminal: true, +}) +if err != nil { + log.Fatal(err) +} + +for event := range watcher.ResultChan() { + fmt.Printf("phase: %s\n", event.Object.Status.Phase) +} +// Channel closes after Ready or Error +``` + +## GetLogs + +Retrieves log entries from a sandbox. The sandbox is looked up by name (the SDK resolves the name to an internal ID automatically). Use functional options to filter results. + +**Available options:** + +| Option | Description | +|--------|-------------| +| `WithLogLines(n uint32)` | Maximum number of log lines to return | +| `WithLogSince(t time.Time)` | Only include entries at or after this time | +| `WithLogSources(sources ...string)` | Filter by source (e.g., `"gateway"`, `"sandbox"`) | +| `WithLogMinLevel(level string)` | Minimum log level (e.g., `"WARN"`, `"ERROR"`) | + +```go +// Get the last 50 log lines +result, err := client.Sandboxes().GetLogs(ctx, "default", "my-sandbox", + v1.WithLogLines(50), +) +for _, line := range result.Lines { + fmt.Printf("[%s] %s: %s\n", line.Level, line.Source, line.Message) +} + +// Filter by source and level since a specific time +result, err := client.Sandboxes().GetLogs(ctx, "default", "my-sandbox", + v1.WithLogSources("gateway"), + v1.WithLogMinLevel("WARN"), + v1.WithLogSince(time.Now().Add(-1*time.Hour)), +) +``` + +The `LogResult` contains: + +- `Lines []LogLine`: log entries in chronological order +- `BufferTotal uint32`: total number of lines available in the server's buffer + +Each `LogLine` has `Timestamp`, `Level`, `Target`, `Message`, `Source`, and `Fields` (structured key-value data). + +See also: [Error Handling](../error-handling.md), [Testing](../testing.md) diff --git a/sdk/go/docs/src/api/services.md b/sdk/go/docs/src/api/services.md new file mode 100644 index 0000000000..6d3195e1ad --- /dev/null +++ b/sdk/go/docs/src/api/services.md @@ -0,0 +1,47 @@ +# Services + +Accessor: `client.Services()` + +Expose, inspect, and manage network services attached to sandboxes. Services provide +external access to ports running inside a sandbox via managed endpoints. + +## Expose + +Expose a port from a sandbox as a named service endpoint. Set `domain` to `true` +to assign a DNS-routable domain name to the service. + +```go +endpoint, err := client.Services().Expose(ctx, "default", "my-sandbox", "web", 8080, true) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Service available at: %s\n", endpoint.URL) +``` + +## List + +List all exposed services for a sandbox. + +```go +services, err := client.Services().List(ctx, "default", "my-sandbox") +if err != nil { + log.Fatal(err) +} +for _, svc := range services { + fmt.Printf(" %s -> port %d (%s)\n", svc.ServiceName, svc.TargetPort, svc.URL) +} +``` + +## Delete + +Remove an exposed service. The underlying sandbox port remains accessible +internally but is no longer reachable through the service endpoint. + +```go +err := client.Services().Delete(ctx, "default", "my-sandbox", "web") +if err != nil { + log.Fatal(err) +} +``` + +See also: [Error Handling](../error-handling.md), [Testing](../testing.md) diff --git a/sdk/go/docs/src/api/ssh.md b/sdk/go/docs/src/api/ssh.md new file mode 100644 index 0000000000..29f2a509e9 --- /dev/null +++ b/sdk/go/docs/src/api/ssh.md @@ -0,0 +1,55 @@ +# SSH + +Accessor: `client.SSH()` + +Create and manage SSH sessions for sandboxes. Supports direct SSH access and +TCP tunneling through SSH connections. + +## CreateSession + +Create a new SSH session for a sandbox. Returns connection details including +host, port, and authentication credentials. + +```go +session, err := client.SSH().CreateSession(ctx, "default", "sandbox-123") +if err != nil { + log.Fatal(err) +} +fmt.Printf("SSH via %s://%s:%d\n", session.GatewayScheme, session.GatewayHost, session.GatewayPort) +``` + +## RevokeSession + +Revoke an active SSH session, immediately terminating any connections using it. + +```go +revoked, err := client.SSH().RevokeSession(ctx, session.Token) +if err != nil { + log.Fatal(err) +} +if revoked { + fmt.Println("Session revoked") +} +``` + +## Tunnel + +Create an SSH tunnel that provides a bidirectional stream to a port inside a +sandbox. This combines SSH session creation with TCP forwarding into a single +operation, returning an `io.ReadWriteCloser` for the tunnel. + +```go +tunnel, err := client.SSH().Tunnel(ctx, "default", "my-sandbox", 8080) +if err != nil { + log.Fatal(err) +} +defer tunnel.Close() + +// Use the tunnel as a regular io.ReadWriteCloser +_, err = tunnel.Write([]byte("GET / HTTP/1.0\r\n\r\n")) +if err != nil { + log.Fatal(err) +} +``` + +See also: [TCP Forwarding](tcp.md), [Error Handling](../error-handling.md) diff --git a/sdk/go/docs/src/api/tcp.md b/sdk/go/docs/src/api/tcp.md new file mode 100644 index 0000000000..025ac1e987 --- /dev/null +++ b/sdk/go/docs/src/api/tcp.md @@ -0,0 +1,60 @@ +# TCP + +Accessor: `client.TCP()` + +Forward TCP connections to sandbox ports using bidirectional gRPC streaming. + +## Forward + +Open a bidirectional TCP forwarding stream to a specific port inside a sandbox. +Returns an `io.ReadWriteCloser` that proxies data between the caller and the +sandbox port over gRPC streaming. + +```go +conn, err := client.TCP().Forward(ctx, "default", "sandbox-123", 8080) +if err != nil { + log.Fatal(err) +} +defer conn.Close() + +// Exchange protocol bytes with the service through the tunnel. +_, err = conn.Write([]byte("ping\n")) +if err != nil { + log.Fatal(err) +} + +buf := make([]byte, 4096) +n, err := conn.Read(buf) +if err != nil { + log.Fatal(err) +} +fmt.Printf("Response: %s\n", buf[:n]) +``` + +## Listen + +Bind a local address that forwards every connection to a sandbox port. The +returned `ForwardListener` owns its accept loop and all bridge goroutines; it is +a lifecycle handle with `Addr` and `Close`, not a `net.Listener`. + +```go +forward, err := client.TCP().Listen(ctx, "default", "sandbox-123", 8080, 0) +if err != nil { + log.Fatal(err) +} +defer forward.Close() + +conn, err := net.Dial("tcp", forward.Addr().String()) +if err != nil { + log.Fatal(err) +} +defer conn.Close() +``` + +Do not pass `ForwardListener` to `http.Serve`. Dial its address with the client +for the protocol exposed by the sandbox service. + +TCP forwarding is lower-level than [SSH tunneling](ssh.md). Use TCP forwarding +when you need direct port access without SSH session overhead. + +See also: [SSH Tunneling](ssh.md), [Error Handling](../error-handling.md) diff --git a/sdk/go/docs/src/architecture.md b/sdk/go/docs/src/architecture.md new file mode 100644 index 0000000000..b08abf09b0 --- /dev/null +++ b/sdk/go/docs/src/architecture.md @@ -0,0 +1,118 @@ +# Architecture + +This page explains how the OpenShell Go SDK is structured internally. Understanding the design helps you navigate the API surface and write idiomatic code. + +## Client Hierarchy + +The SDK follows the Kubernetes client-go sub-client pattern. A single `Client` provides typed accessors for each API domain: + +```text +Client +├── Sandboxes() → SandboxInterface +├── Exec() → ExecInterface +├── Providers() → ProviderInterface +│ ├── Profiles() → ProfileInterface +│ └── Refresh() → RefreshInterface +├── Services() → ServiceInterface +├── Files() → FileInterface +├── Health() → HealthInterface +├── SSH() → SSHInterface +├── TCP() → TCPInterface +├── Config() → ConfigInterface +└── Policy() → PolicyInterface +``` + +Each accessor returns an interface. You work with the interface, not the concrete implementation. This makes the sub-clients easy to mock and test. + +## Creating a Client + +All interaction starts with `NewClient`: + +```go +client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: v1.StaticToken("my-token"), +}) +if err != nil { + log.Fatal(err) +} +defer client.Close() +``` + +The `Config` struct controls: + +| Field | Purpose | +|-------|---------| +| `Address` | Gateway host and port | +| `Auth` | Authentication provider (`StaticToken`, `NoAuth`, or custom) | +| `TLS` | TLS settings (CA cert, skip verify, client certs) | +| `Retry` | Retry policy for transient failures | + +## Proto Isolation + +The SDK never exposes protobuf-generated types in its public API. Instead, it defines its own Go types (in `openshell/v1/`) and converts to/from proto at the gRPC boundary. + +This means: + +- Your code imports `openshell/v1`, not `proto/openshellv1` +- You work with plain Go structs, not proto messages +- Proto schema changes in upstream OpenShell do not break your code (the SDK adapts internally) +- You can use standard Go patterns (json.Marshal, fmt.Sprintf, reflect) on SDK types without proto constraints + +The conversion layer lives in `openshell/v1/internal/converter/` and is not part of the public API. + +## Sub-Client Pattern + +Each sub-client groups methods for a specific API domain. For example, `SandboxInterface` provides `Create`, `Get`, `List`, `Delete`, `WaitReady`, `Watch`, and more. + +Sub-clients are cheap to access. They are created once when the `Client` is initialized and reuse the same underlying gRPC connection: + +```go +// These return the same sub-client instance every time +sandboxes := client.Sandboxes() +exec := client.Exec() +``` + +Some sub-clients have their own sub-clients. `ProviderInterface` exposes `Profiles()` and `Refresh()`: + +```go +profiles, err := client.Providers().Profiles().List(ctx, "default") +status, err := client.Providers().Refresh().GetStatus(ctx, "default", "openai", "api-key") +``` + +## gRPC Layer + +Underneath, the SDK communicates with the OpenShell gateway over gRPC. The single `OpenShell` service in `proto/openshell.proto` defines all RPCs. The SDK maps each interface method to one or more RPCs: + +| Pattern | Example | +|---------|---------| +| Unary RPC | `Create`, `Get`, `Delete` | +| Server-streaming RPC | `Watch`, `Stream`, `GetLogs` | +| Client-streaming RPC | File uploads | +| Bidirectional streaming | `Interactive`, `Forward` | + +The gRPC connection is managed by the `Client`. Calling `client.Close()` cleanly shuts down all active streams and the underlying connection. + +## Error Model + +All SDK methods return standard Go errors. Errors from the gateway carry a `StatusError` with a typed error code. Use the `Is*` functions to classify errors: + +```go +_, err := client.Sandboxes().Get(ctx, "default", "missing") +if v1.IsNotFound(err) { + // sandbox does not exist +} +``` + +See the [Error Handling](error-handling.md) guide for the complete list of error checks and retry patterns. + +## Fake Client + +For testing, the SDK provides `openshell/v1/fake` with an in-memory implementation of `ClientInterface`. The fake client supports fixture seeding, watch events, and health simulation: + +```go +fc := fake.NewClient() +fc.AddSandbox(&v1.Sandbox{Name: "test-sb", Status: v1.SandboxStatus{Phase: v1.SandboxReady}}) +``` + +See the [Testing](testing.md) guide for complete examples. diff --git a/sdk/go/docs/src/error-handling.md b/sdk/go/docs/src/error-handling.md new file mode 100644 index 0000000000..f46b796513 --- /dev/null +++ b/sdk/go/docs/src/error-handling.md @@ -0,0 +1,195 @@ +# Error Handling + +Gateway status failures and SDK validation failures use `*v1.StatusError`, which carries a machine-readable `Code` and a human-readable `Message`. Local I/O, transport setup, and sentinel errors may use other Go error types. The SDK provides predicate functions for classified status errors. + +## StatusError + +When an operation returns a classified status error, inspect it directly or use the convenience predicates below. Use `errors.Is` for documented sentinel errors such as `ErrTransportNotAvailable`. + +```go +var se *v1.StatusError +if errors.As(err, &se) { + fmt.Printf("code: %s, message: %s\n", se.Code, se.Message) + // se.Details contains optional structured metadata +} +``` + +| Field | Type | Description | +|-----------|-------------------|--------------------------------------| +| `Code` | `ErrorCode` | Machine-readable error classification | +| `Message` | `string` | Human-readable error description | +| `Details` | `map[string]string` | Optional structured metadata | + +## Predicate Functions + +Use these top-level functions to check error types. They work with wrapped errors via `errors.As`. + +| Function | ErrorCode | When it fires | +|----------------------|----------------------|-------------------------------------------------| +| `v1.IsNotFound` | `ErrorNotFound` | Resource does not exist (sandbox, provider, etc.) | +| `v1.IsAlreadyExists` | `ErrorAlreadyExists` | Resource with that name already exists | +| `v1.IsConflict` | `ErrorConflict` | Optimistic concurrency violation or invalid state transition | +| `v1.IsUnavailable` | `ErrorUnavailable` | Gateway is unreachable or client is closed | +| `v1.IsUnimplemented` | `ErrorUnimplemented` | Operation not supported by the gateway version | +| `v1.IsPermissionDenied` | `ErrorPermissionDenied` | Insufficient permissions | +| `v1.IsInvalidArgument` | `ErrorInvalidArgument` | Invalid request parameters | +| `v1.IsDeadlineExceeded` | `ErrorDeadlineExceeded` | Operation timed out | +| `v1.IsCancelled` | `ErrorCancelled` | Operation was cancelled (context cancellation) | + +For `ErrorInternal` (server-side errors), no convenience predicate exists. Match it directly via the `Code` field: + +```go +var se *v1.StatusError +if errors.As(err, &se) && se.Code == v1.ErrorInternal { + fmt.Println("Internal server error:", se.Message) +} +``` + +## Common Patterns + +### Not Found + +Handle missing resources gracefully: + +```go +sb, err := client.Sandboxes().Get(ctx, "default", "my-sandbox") +if v1.IsNotFound(err) { + fmt.Println("Sandbox does not exist, creating...") + sb, err = client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) +} +if err != nil { + log.Fatal(err) +} +``` + +### Already Exists + +Guard against duplicate creation: + +```go +_, err := client.Providers().Create(ctx, "default", &v1.Provider{ + Name: "openai", + Type: "openai", +}) +if v1.IsAlreadyExists(err) { + fmt.Println("Provider already registered, skipping") +} else if err != nil { + log.Fatal(err) +} +``` + +Alternatively, use `Ensure` for idempotent registration: + +```go +// Create-or-update in a single call +provider, err := client.Providers().Ensure(ctx, "default", &v1.Provider{ + Name: "openai", + Type: "openai", +}) +``` + +### Conflict (Optimistic Concurrency) + +When two clients modify the same resource concurrently, the second write receives a conflict error. Retry by re-reading the resource: + +```go +sb, _ := client.Sandboxes().Get(ctx, "default", "my-sandbox") + +result, err := client.Sandboxes().AttachProvider(ctx, + "default", "my-sandbox", "openai", sb.ResourceVersion, +) +if v1.IsConflict(err) { + // Another client modified the sandbox — re-read and retry + sb, _ = client.Sandboxes().Get(ctx, "default", "my-sandbox") + result, err = client.Sandboxes().AttachProvider(ctx, + "default", "my-sandbox", "openai", sb.ResourceVersion, + ) +} +if err != nil { + log.Fatal(err) +} +``` + +### Unavailable + +Handle gateway connectivity issues: + +```go +sb, err := client.Sandboxes().Get(ctx, "default", "my-sandbox") +if v1.IsUnavailable(err) { + fmt.Println("Gateway is not reachable, check connection") + // Implement retry with backoff +} +``` + +### Unimplemented + +Detect unsupported operations gracefully: + +```go +_, err := client.Sandboxes().GetLogs(ctx, "default", "my-sandbox") +if v1.IsUnimplemented(err) { + fmt.Println("Log retrieval not supported by this gateway version") +} +``` + +## Retry with Backoff + +For transient errors like `Unavailable` or `DeadlineExceeded`, use exponential backoff only when the operation is idempotent or carries an idempotency/concurrency token. A timeout does not prove that a mutating request was not applied. + +```go +func withRetry(ctx context.Context, maxAttempts int, fn func() error) error { + backoff := 100 * time.Millisecond + + for attempt := 0; attempt < maxAttempts; attempt++ { + err := fn() + if err == nil { + return nil + } + + // Only retry transient errors + if !v1.IsUnavailable(err) && !v1.IsDeadlineExceeded(err) { + return err + } + + // Add jitter to prevent thundering herd after outages + jitter := time.Duration(rand.Int63n(int64(backoff) / 2)) + + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(backoff + jitter): + backoff *= 2 + } + } + return fmt.Errorf("exhausted %d retry attempts", maxAttempts) +} +``` + +Usage: + +```go +var sb *v1.Sandbox +err := withRetry(ctx, 3, func() error { + var e error + sb, e = client.Sandboxes().Get(ctx, "default", "my-sandbox") + return e +}) +``` + +## Context Cancellation + +All SDK methods accept a `context.Context`. Use context deadlines and cancellation to control timeouts: + +```go +// 5-second timeout for a single call +ctx, cancel := context.WithTimeout(ctx, 5*time.Second) +defer cancel() + +sb, err := client.Sandboxes().Get(ctx, "default", "my-sandbox") +if v1.IsDeadlineExceeded(err) { + fmt.Println("Request timed out") +} +``` + +See also: [Testing](testing.md) for how the fake client returns the same error codes. diff --git a/sdk/go/docs/src/getting-started.md b/sdk/go/docs/src/getting-started.md new file mode 100644 index 0000000000..0ea3ac404a --- /dev/null +++ b/sdk/go/docs/src/getting-started.md @@ -0,0 +1,123 @@ +# Quick Start + +This guide walks you through installing the OpenShell Go SDK, connecting to a gateway, creating a sandbox, running a command, and cleaning up. You should be up and running in under 5 minutes. + +## Prerequisites + +- Go 1.25 or later +- Access to an OpenShell gateway (address and authentication token) + +## Installation + +Add the SDK to your Go module: + +```bash +go get github.com/NVIDIA/OpenShell/sdk/go@latest +``` + +## Connect to the Gateway + +Create a client by providing the gateway address and authentication credentials: + +```go +package main + +import ( + "context" + "fmt" + "log" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +func main() { + client, err := v1.NewClient(v1.Config{ + Address: "gateway.example.com:443", + Auth: v1.StaticToken("my-token"), + }) + if err != nil { + log.Fatal(err) + } + defer client.Close() +``` + +For production use, load the token from an environment variable instead of hardcoding it: +`v1.StaticToken(os.Getenv("OPENSHELL_TOKEN"))` + +The `Config` struct accepts optional fields for TLS configuration and retry policies. For development against a local gateway without TLS, use `v1.NoAuth()` and set TLS to skip verification. + +## Check Gateway Health + +Verify the gateway is reachable: + +```go + ctx := context.Background() + + health, err := client.Health().Check(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Gateway healthy: %v\n", health.Healthy) +``` + +## Create a Sandbox + +Create a sandbox with a Python image: + +```go + sandbox, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{ + Template: &v1.SandboxTemplate{Image: "python:3.12"}, + Environment: map[string]string{"LANG": "en_US.UTF-8"}, + }, nil) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Created sandbox: %s\n", sandbox.Name) +``` + +## Wait for the Sandbox to be Ready + +Sandboxes take a moment to provision. Use `WaitReady` to block until the sandbox is ready to accept commands: + +```go + sandbox, err = client.Sandboxes().WaitReady(ctx, "default", sandbox.Name) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Sandbox is ready (phase: %s)\n", sandbox.Status.Phase) +``` + +## Run a Command + +Execute a command inside the sandbox: + +```go + result, err := client.Exec().Run(ctx, "default", sandbox.Name, []string{"echo", "hello from OpenShell"}) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Output: %s", result.Stdout) + fmt.Printf("Exit code: %d\n", result.ExitCode) +``` + +For long-running commands, use `Stream` to receive output incrementally, or `Interactive` for terminal-like sessions. + +## Clean Up + +Delete the sandbox when you are done: + +```go + err = client.Sandboxes().Delete(ctx, "default", sandbox.Name) + if err != nil { + log.Fatal(err) + } + fmt.Println("Sandbox deleted") +} +``` + +## Next Steps + +- Browse the [API Overview](api/overview.md) to see all available interfaces +- Learn about [Error Handling](error-handling.md) for production code +- Set up [Testing](testing.md) with the fake client for your test suites +- Explore the [Architecture](architecture.md) to understand the SDK design diff --git a/sdk/go/docs/src/introduction.md b/sdk/go/docs/src/introduction.md new file mode 100644 index 0000000000..61f3a05ca2 --- /dev/null +++ b/sdk/go/docs/src/introduction.md @@ -0,0 +1,34 @@ +# OpenShell Go SDK + +The OpenShell Go SDK provides an idiomatic Go client for the OpenShell gateway API. It wraps the underlying gRPC protocol behind typed interfaces, making it straightforward to manage sandboxes, execute commands, handle providers, and more. + +## Key Features + +- **Sub-client pattern**: A single `Client` provides typed accessors for each API domain (Sandboxes, Exec, Providers, Files, Health, SSH, TCP, Config, Policy, Services) +- **Clean types**: SDK types are self-contained, so your code works with idiomatic Go types without extra dependencies +- **Fake client for testing**: An in-memory implementation of the full `ClientInterface` for testing without a live gateway +- **Watch and streaming**: First-class support for watching sandbox state changes and streaming command output +- **Typed error handling**: Functions like `IsNotFound`, `IsAlreadyExists`, and `IsConflict` for precise error classification + +## Where to Start + +If you are new to the SDK, the [Quick Start](getting-started.md) guide walks you through installation, connecting to a gateway, creating your first sandbox, running a command, and cleaning up. + +For a deeper understanding of how the SDK is structured, see the [Architecture](architecture.md) overview. + +## API Reference + +Every SDK interface has a dedicated reference page with method signatures and code examples. + +Browse the full [API Overview](api/overview.md) to see all 13 interfaces at a glance. + +## Guides + +- [Error Handling](error-handling.md): StatusError, typed error checks, retry patterns +- [Testing](testing.md): Fake client usage, fixture seeding, watch event testing + +## Related Projects + +- [**OpenShell**](https://github.com/NVIDIA/OpenShell) (by NVIDIA): The upstream project that defines the gateway API and sandbox runtime this SDK wraps. +- [**openshell-sdk-go**](https://github.com/NVIDIA/OpenShell/sdk/go): This SDK's source repository on GitHub. +- [**pkg.go.dev**](https://pkg.go.dev/github.com/NVIDIA/OpenShell/sdk/go/openshell/v1): Go package documentation with type signatures and godoc. diff --git a/sdk/go/docs/src/testing.md b/sdk/go/docs/src/testing.md new file mode 100644 index 0000000000..af1851ae83 --- /dev/null +++ b/sdk/go/docs/src/testing.md @@ -0,0 +1,184 @@ +# Testing + +The SDK ships a `fake` package that provides an in-memory implementation of all client interfaces. Use it in your test suites to exercise SDK interactions without a real gateway. + +```go +import "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" +``` + +The fake client follows the same pattern as `k8s.io/client-go/kubernetes/fake`: it maintains in-memory stores, supports watch event broadcasting, and returns the same `StatusError` codes as the real client. + +## Creating a Fake Client + +```go +func TestMyOperator(t *testing.T) { + client := fake.NewClient() + defer client.Close() + + ctx := context.Background() + + // Use client exactly like the real SDK + sb, err := client.Sandboxes().Create(ctx, "default", "test-sandbox", &v1.SandboxSpec{}, nil) + require.NoError(t, err) + assert.Equal(t, "Provisioning", string(sb.Status.Phase)) +} +``` + +The returned `*fake.Client` satisfies `v1.ClientInterface`, so you can pass it anywhere your code accepts the interface. + +## Fixture Seeding + +Pre-populate the fake client with existing resources before your test runs. Seeded resources are available immediately via `Get` and `List` without going through `Create`. + +### AddSandbox + +```go +client := fake.NewClient() + +// Pre-seed a sandbox that already exists +client.AddSandbox(&types.Sandbox{ + Name: "existing-sandbox", + Status: types.SandboxStatus{ + Phase: types.SandboxReady, + }, + ResourceVersion: 5, +}) + +// Now Get returns it immediately +sb, err := client.Sandboxes().Get(ctx, "default", "existing-sandbox") +// sb.Status.Phase == "Ready" +``` + +### AddProvider + +```go +client := fake.NewClient() + +// Pre-seed a provider +client.AddProvider(&types.Provider{ + Name: "my-openai", + Type: "openai", + Spec: types.ProviderSpec{ + Credentials: map[string]string{ + "api_key": "sk-test-key", + }, + }, +}) + +// List returns the seeded provider +providers, _ := client.Providers().List(ctx, "default") +// len(providers) == 1 +``` + +## Sandbox Lifecycle + +The fake client implements the full sandbox lifecycle. Created sandboxes start in the `Provisioning` phase. Calling `WaitReady` transitions them to `Ready` synchronously. + +```go +client := fake.NewClient() +ctx := context.Background() + +// Create starts in Provisioning +sb, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) +assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) + +// WaitReady transitions to Ready (synchronous in fake) +sb, err = client.Sandboxes().WaitReady(ctx, "default", "my-sandbox") +assert.Equal(t, types.SandboxReady, sb.Status.Phase) + +// Delete removes the sandbox +err = client.Sandboxes().Delete(ctx, "default", "my-sandbox") +assert.NoError(t, err) + +// Get after delete returns NotFound +_, err = client.Sandboxes().Get(ctx, "default", "my-sandbox") +assert.True(t, v1.IsNotFound(err)) +``` + +## Watch Events + +The fake client broadcasts watch events when resources change. Use watchers to test event-driven code. + +```go +client := fake.NewClient() +ctx := context.Background() + +// Start watching before making changes +watcher, err := client.Sandboxes().Watch(ctx, "default", "my-sandbox") +require.NoError(t, err) +defer watcher.Stop() + +// Create a sandbox — triggers an ADDED event +client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) + +// Read the event from the channel +event := <-watcher.ResultChan() +assert.Equal(t, types.EventAdded, event.Type) +assert.Equal(t, "my-sandbox", event.Object.Name) +``` + +### StopOnTerminal + +Setting `StopOnTerminal: true` causes the watcher to close automatically when the sandbox reaches a terminal phase (`Ready` or `Error`). + +```go +watcher, err := client.Sandboxes().Watch(ctx, "default", "my-sandbox", v1.WatchOptions{ + StopOnTerminal: true, +}) +require.NoError(t, err) + +// Create and transition to Ready +client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) +client.Sandboxes().WaitReady(ctx, "default", "my-sandbox") + +// Drain events — channel closes after the Ready event +var events []types.Event[*types.Sandbox] +for ev := range watcher.ResultChan() { + events = append(events, ev) +} +// Channel is now closed +``` + +## Health Simulation + +Use `WithHealthResult` to simulate an unhealthy or degraded gateway. + +```go +// Default: healthy gateway +client := fake.NewClient() +result, _ := client.Health().Check(ctx) +// result.Healthy == true, result.Version == "fake" + +// Simulate unhealthy gateway +client = fake.NewClient(fake.WithHealthResult(&types.HealthResult{ + Healthy: false, + Version: "1.2.3", +})) +result, _ = client.Health().Check(ctx) +// result.Healthy == false +``` + +## Error Behavior + +The fake client returns the same `StatusError` codes as the real client: + +| Scenario | Error Code | +|----------|------------| +| `Get` for a non-existent resource | `ErrorNotFound` | +| `Create` with a duplicate name | `ErrorAlreadyExists` | +| Any call after `Close()` | `ErrorUnavailable` | +| Unimplemented operations (e.g., `GetLogs`) | `ErrorUnimplemented` | + +```go +client := fake.NewClient() +client.Close() + +_, err := client.Sandboxes().Get(ctx, "default", "anything") +assert.True(t, v1.IsUnavailable(err)) +``` + +## Concurrency + +All fake client operations are safe for concurrent use. The internal stores use mutex-based synchronization. This means you can safely use the fake client from multiple goroutines in parallel tests. + +See also: [Error Handling](error-handling.md), [API Reference](api/overview.md) diff --git a/sdk/go/docs/theme/custom.css b/sdk/go/docs/theme/custom.css new file mode 100644 index 0000000000..74b7011144 --- /dev/null +++ b/sdk/go/docs/theme/custom.css @@ -0,0 +1,179 @@ +/* OpenShell Go SDK - Custom Typography + * + * Font: Inter from Google Fonts CDN + * Base size: 18px, line-height: 1.7 + * Max content width: 800px + */ + +@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); + +:root { + --content-max-width: none; +} + +/* Base typography */ +body, +.content, +.sidebar { + font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; +} + +.content { + font-size: 18px; + line-height: 1.7; + max-width: none; + padding: 0 4em; +} + +/* Headings */ +.content h1 { + font-size: 2em; + font-weight: 700; + margin-top: 1.5em; + margin-bottom: 0.5em; + line-height: 1.2; +} + +.content h2 { + font-size: 1.5em; + font-weight: 600; + margin-top: 1.8em; + margin-bottom: 0.4em; + line-height: 1.3; + border-bottom: 1px solid var(--sidebar-separator); + padding-bottom: 0.3em; +} + +.content h3 { + font-size: 1.25em; + font-weight: 600; + margin-top: 1.5em; + margin-bottom: 0.3em; + line-height: 1.4; +} + +.content h4 { + font-size: 1.1em; + font-weight: 600; + margin-top: 1.2em; + margin-bottom: 0.3em; +} + +/* Paragraphs */ +.content p { + margin-bottom: 1em; +} + +/* Code blocks */ +.content pre { + font-size: 15px; + line-height: 1.5; + padding: 0; + border-radius: 6px; + margin: 1em 0; + overflow-x: auto; + max-width: 100%; +} + +.content code { + font-size: 0.875em; + padding: 0.15em 0.35em; + border-radius: 3px; +} + +.content pre > code, +.content pre > code.hljs { + font-size: 16px; + padding: 1em 1em !important; + display: block; +} + +/* Tables */ +.content table { + font-size: 16px; + width: 100%; + margin: 1em 0; + border-collapse: collapse; +} + +.content th { + font-weight: 600; + text-align: left; + padding: 0.6em 1em; + border-bottom: 2px solid var(--sidebar-separator); +} + +.content td { + padding: 0.5em 1em; + border-bottom: 1px solid var(--sidebar-separator); +} + +/* Sidebar adjustments */ +.sidebar .sidebar-scrollbox { + font-size: 17px; +} + +/* Hide chapter numbers (mdBook 0.5.x uses inside links) */ +.sidebar ol.chapter li.chapter-item a > strong { + display: none; +} + +/* Main sidebar items */ +.sidebar ol.chapter li.chapter-item { + line-height: 1.7; + margin: 0; + padding: 0; + padding-left: 1em; +} + +/* On-this-page sub-navigation */ +.sidebar .on-this-page li { + line-height: 1.8; + margin: 0; + padding: 0; +} + +.sidebar .on-this-page { + margin-top: 0.3em; +} + +.sidebar .on-this-page ol.section { + padding-left: 1em; + margin: 0; +} + +/* Section headers: clear visual break */ +.sidebar ol.chapter li.part-title { + margin-top: 1.2em; + margin-bottom: 0.4em; + padding-bottom: 0.2em; + font-weight: 700; + font-size: 1.15em; + letter-spacing: 0.03em; + opacity: 0.9; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +/* Lists */ +.content li { + margin-bottom: 0.3em; +} + +/* Navigation arrows: smaller, less intrusive */ +.nav-chapters { + font-size: 2em; + max-width: 40px; + opacity: 0.4; +} + +.nav-chapters:hover { + opacity: 0.8; +} + +/* Blockquotes */ +.content blockquote { + margin: 1em 0; + padding: 0.5em 1.2em; + border-left: 4px solid var(--sidebar-separator); + font-style: normal; +} diff --git a/sdk/go/go.mod b/sdk/go/go.mod index 4a7c16017b..900c9520c6 100644 --- a/sdk/go/go.mod +++ b/sdk/go/go.mod @@ -1,22 +1,22 @@ module github.com/NVIDIA/OpenShell/sdk/go -go 1.24.0 - -toolchain go1.26.4 +go 1.25.0 require ( + github.com/coder/websocket v1.8.15 github.com/stretchr/testify v1.11.1 - golang.org/x/oauth2 v0.35.0 - google.golang.org/grpc v1.80.0 + golang.org/x/oauth2 v0.36.0 + golang.org/x/sync v0.22.0 + google.golang.org/grpc v1.81.1 google.golang.org/protobuf v1.36.11 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - golang.org/x/net v0.49.0 // indirect - golang.org/x/sys v0.41.0 // indirect - golang.org/x/text v0.33.0 // indirect - google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect + golang.org/x/net v0.51.0 // indirect + golang.org/x/sys v0.42.0 // indirect + golang.org/x/text v0.34.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/sdk/go/go.sum b/sdk/go/go.sum index 5b0f5d0056..44508540a6 100644 --- a/sdk/go/go.sum +++ b/sdk/go/go.sum @@ -1,5 +1,7 @@ github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/coder/websocket v1.8.15 h1:6B2JPeOGlpff2Uz6vOEH1Vzpi0iUz20A+lPVhPHtNUA= +github.com/coder/websocket v1.8.15/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= @@ -18,30 +20,32 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= -go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48= -go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8= -go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0= -go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs= -go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18= -go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE= -go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8= -go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew= -go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI= -go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA= -golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o= -golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8= -golang.org/x/oauth2 v0.35.0 h1:Mv2mzuHuZuY2+bkyWXIHMfhNdJAdwW3FuWeCPYN5GVQ= -golang.org/x/oauth2 v0.35.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA= -golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= -golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= -golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE= -golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8= +go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I= +go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0= +go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM= +go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY= +go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg= +go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg= +go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw= +go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A= +go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A= +go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= +golang.org/x/oauth2 v0.36.0 h1:peZ/1z27fi9hUOFCAZaHyrpWG5lwe0RJEEEeH0ThlIs= +golang.org/x/oauth2 v0.36.0/go.mod h1:YDBUJMTkDnJS+A4BP4eZBjCqtokkg1hODuPjwiGPO7Q= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= +golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= +golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= -google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= diff --git a/sdk/go/mise.toml b/sdk/go/mise.toml new file mode 100644 index 0000000000..c7f216b1cf --- /dev/null +++ b/sdk/go/mise.toml @@ -0,0 +1,154 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +[tools] +go = "1.25" +buf = "1.72.0" +"go:github.com/golangci/golangci-lint/v2/cmd/golangci-lint" = "2.12" +"go:google.golang.org/protobuf/cmd/protoc-gen-go" = "1.36.11" +"go:google.golang.org/grpc/cmd/protoc-gen-go-grpc" = "1.6.2" + +[tasks.test] +description = "Run unit tests with coverage" +run = "go test -coverprofile=coverage.out -coverpkg=./openshell/... -race ./..." + +[tasks."test:integration"] +description = "Run integration tests" +run = "go test -tags=integration -race ./..." + +[tasks.lint] +description = "Run linter" +run = "golangci-lint run ./..." + +[tasks.fmt] +description = "Format code" +run = "goimports -w . && go fmt ./..." + +[tasks.build] +description = "Build all packages" +run = "go build ./..." + +[tasks.ci] +description = "Run full CI pipeline" +depends = ["lint", "build", "test", "proto:check", "docs:check"] + +[tasks."docs:check"] +description = "Verify every public package has a docs page" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +DOCS_DIR="docs/src/api" +SUMMARY="docs/src/SUMMARY.md" +MISSING=0 + +# Find all public packages with a doc.go (excluding internal, proto, types) +for docfile in openshell/v1/*/doc.go; do + pkg=$(basename "$(dirname "$docfile")") + + # Skip internal packages and types (no user-facing docs needed) + case "$pkg" in + internal|types) continue ;; + esac + + # Check for matching docs page + if [ ! -f "$DOCS_DIR/$pkg.md" ]; then + echo "MISSING: $DOCS_DIR/$pkg.md (package openshell/v1/$pkg has doc.go but no docs page)" + MISSING=$((MISSING + 1)) + fi + + # Check for SUMMARY.md entry + if ! grep -q "api/$pkg.md" "$SUMMARY" 2>/dev/null; then + echo "MISSING: SUMMARY.md entry for api/$pkg.md" + MISSING=$((MISSING + 1)) + fi +done + +if [ "$MISSING" -gt 0 ]; then + echo "" + echo "ERROR: $MISSING documentation gaps found." + echo "Every public package with doc.go needs a docs/src/api/.md page" + echo "and a SUMMARY.md entry. See Constitution XIII." + exit 1 +fi + +echo "Docs check passed: all public packages have documentation." +""" + +[tasks."proto:gen"] +description = "Generate Go bindings from proto files using buf" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +SDK_ROOT=$(pwd -P) +REPO_ROOT=$(cd ../.. && pwd -P) + +for tool in buf protoc-gen-go protoc-gen-go-grpc; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Run 'mise install' to install it." + exit 1 + fi +done + +if find proto -maxdepth 1 -name '*.proto' -print -quit | grep -q .; then + echo "ERROR: sdk/go/proto must contain generated bindings only." + echo "Proto sources belong in the repository root proto/ directory." + exit 1 +fi + +# Clean previous output before regeneration +find proto -name '*.pb.go' -delete 2>/dev/null || true +find proto -mindepth 1 -type d -empty -delete 2>/dev/null || true + +(cd "$REPO_ROOT" && buf generate --template "$SDK_ROOT/buf.gen.yaml") + +echo "Proto generation complete." +echo "Generated packages:" +for pkg_dir in proto/*/; do + count=$(find "$pkg_dir" -maxdepth 1 -name '*.go' | wc -l | tr -d ' ') + echo " $pkg_dir: $count files" +done +""" + +[tasks."proto:check"] +description = "Verify generated proto files are up to date" +run = """ +#!/usr/bin/env bash +set -euo pipefail + +SDK_ROOT=$(pwd -P) +REPO_ROOT=$(cd ../.. && pwd -P) + +for tool in buf protoc-gen-go protoc-gen-go-grpc; do + if ! command -v "$tool" &>/dev/null; then + echo "ERROR: $tool not found. Run 'mise install' to install it." + exit 1 + fi +done + +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT + +if find proto -maxdepth 1 -name '*.proto' -print -quit | grep -q .; then + echo "ERROR: sdk/go/proto contains copied proto sources." + echo "Proto sources belong in the repository root proto/ directory." + exit 1 +fi + +# Generate to temp directory with adjusted output path +CHECK_TEMPLATE=$(sed 's|out: sdk/go|out: '"$WORK_DIR"'|' buf.gen.yaml) +(cd "$REPO_ROOT" && buf generate --template "$CHECK_TEMPLATE") + +DIFF_OUTPUT=$(diff -r "$WORK_DIR/proto" "$SDK_ROOT/proto" 2>&1) || true + +if [ -n "$DIFF_OUTPUT" ]; then + echo "ERROR: Generated proto files are out of date." + echo "Run 'mise run proto:gen' to regenerate." + echo "" + echo "$DIFF_OUTPUT" + exit 1 +fi + +echo "Proto check passed: generated files are up to date." +""" diff --git a/sdk/go/openshell/v1/auth_refresh.go b/sdk/go/openshell/v1/auth_refresh.go index a3cf8b5836..ef7a6c8743 100644 --- a/sdk/go/openshell/v1/auth_refresh.go +++ b/sdk/go/openshell/v1/auth_refresh.go @@ -10,11 +10,16 @@ import ( "time" "golang.org/x/oauth2" + "golang.org/x/sync/singleflight" "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" ) -const defaultLeeway = 10 * time.Second +const ( + defaultLeeway = 10 * time.Second + initialBackoff = 1 * time.Second + maxBackoff = 30 * time.Second +) var errNilTokenSource = errors.New("openshell: TokenSource must not be nil") @@ -52,11 +57,14 @@ func WithLogger(l types.Logger) RefreshOption { } type refreshableAuth struct { - source oauth2.TokenSource - mu sync.RWMutex - tok *oauth2.Token - leeway time.Duration - logger types.Logger + source oauth2.TokenSource + mu sync.Mutex + group singleflight.Group + tok *oauth2.Token + leeway time.Duration + logger types.Logger + nextRetry time.Time + backoff time.Duration } func (r *refreshableAuth) isTokenValid() bool { @@ -69,46 +77,72 @@ func (r *refreshableAuth) isTokenValid() bool { return time.Now().Before(r.tok.Expiry.Add(-r.leeway)) } -func (r *refreshableAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { - // Fast path: RLock, return cached token if valid. - r.mu.RLock() +func (r *refreshableAuth) GetRequestMetadata(ctx context.Context, _ ...string) (map[string]string, error) { + r.mu.Lock() if r.isTokenValid() { tok := r.tok.AccessToken - r.mu.RUnlock() + r.mu.Unlock() return map[string]string{"authorization": "Bearer " + tok}, nil } - r.mu.RUnlock() - // Slow path: Lock, re-check, fetch if still stale. + if !r.nextRetry.IsZero() && time.Now().Before(r.nextRetry) { + if r.tok != nil { + tok := r.tok.AccessToken + r.mu.Unlock() + return map[string]string{"authorization": "Bearer " + tok}, nil + } + r.mu.Unlock() + return nil, errors.New("openshell: token refresh failed and backoff is active") + } + r.mu.Unlock() + + resultCh := r.group.DoChan("refresh", func() (any, error) { + return r.source.Token() + }) + var val any + var err error + select { + case <-ctx.Done(): + return nil, ctx.Err() + case result := <-resultCh: + val = result.Val + err = result.Err + } + r.mu.Lock() defer r.mu.Unlock() - if r.isTokenValid() { - return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil - } - - newTok, err := r.source.Token() if err != nil { - if r.tok != nil { - if r.logger != nil { - r.logger.Error(err, "token refresh failed, using cached token") + if r.nextRetry.IsZero() || !time.Now().Before(r.nextRetry) { + bo := r.backoff + if bo == 0 { + bo = initialBackoff + } else { + bo *= 2 + if bo > maxBackoff { + bo = maxBackoff + } } - return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil + r.backoff = bo + r.nextRetry = time.Now().Add(bo) } - return nil, err - } - if newTok == nil { if r.tok != nil { if r.logger != nil { - r.logger.Error(errors.New("token source returned nil token"), "token refresh returned nil, using cached token") + r.logger.Error(err, "token refresh failed, using cached token") } return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil } - return nil, errors.New("openshell: token source returned nil token") + return nil, err } - r.tok = newTok + tok, ok := val.(*oauth2.Token) + if !ok || tok == nil { + return nil, errors.New("openshell: token source returned nil token without error") + } + r.tok = tok + r.backoff = 0 + r.nextRetry = time.Time{} return map[string]string{"authorization": "Bearer " + r.tok.AccessToken}, nil } @@ -118,7 +152,8 @@ func (r *refreshableAuth) RequireTransportSecurity() bool { // RefreshableToken returns an AuthProvider that caches tokens from src // and refreshes them before expiry. Concurrent callers share a single -// refresh call (coalesced via RWMutex double-checked locking). +// in-flight refresh via singleflight. Failed refreshes trigger exponential +// backoff (1s, 2s, 4s, ..., 30s cap) to avoid amplifying token endpoint outages. func RefreshableToken(src oauth2.TokenSource, opts ...RefreshOption) (AuthProvider, error) { if src == nil { return nil, errNilTokenSource diff --git a/sdk/go/openshell/v1/auth_refresh_test.go b/sdk/go/openshell/v1/auth_refresh_test.go index 451e8e63df..dc0b1ec16a 100644 --- a/sdk/go/openshell/v1/auth_refresh_test.go +++ b/sdk/go/openshell/v1/auth_refresh_test.go @@ -55,6 +55,25 @@ func TestRefreshableToken_ValidSource(t *testing.T) { assert.NotNil(t, provider) } +func TestGetRequestMetadata_ReturnsWhenContextCanceledDuringRefresh(t *testing.T) { + release := make(chan struct{}) + t.Cleanup(func() { close(release) }) + src := &mockTokenSource{tokenFunc: func() (*oauth2.Token, error) { + <-release + return &oauth2.Token{AccessToken: "late-token"}, nil + }} + provider, err := RefreshableToken(src) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() + started := time.Now() + _, err = provider.GetRequestMetadata(ctx) + + require.ErrorIs(t, err, context.Canceled) + assert.Less(t, time.Since(started), time.Second) +} + // --- Phase 3 / US1 tests: automatic token refresh --- func TestGetRequestMetadata_FirstCallFetchesToken(t *testing.T) { @@ -121,22 +140,24 @@ func TestGetRequestMetadata_ConcurrentSingleFlight(t *testing.T) { src := &mockTokenSource{ tokenFunc: func() (*oauth2.Token, error) { fetchCount.Add(1) - time.Sleep(10 * time.Millisecond) // simulate slow token fetch + time.Sleep(100 * time.Millisecond) return &oauth2.Token{AccessToken: "shared-token", Expiry: time.Now().Add(time.Hour)}, nil }, } provider, err := RefreshableToken(src) require.NoError(t, err) - const goroutines = 1000 + const goroutines = 20 var wg sync.WaitGroup wg.Add(goroutines) + ready := make(chan struct{}) results := make([]string, goroutines) errs := make([]error, goroutines) for i := range goroutines { go func(idx int) { defer wg.Done() + <-ready md, e := provider.GetRequestMetadata(context.Background()) errs[idx] = e if md != nil { @@ -144,6 +165,7 @@ func TestGetRequestMetadata_ConcurrentSingleFlight(t *testing.T) { } }(i) } + close(ready) wg.Wait() for i := range goroutines { @@ -318,6 +340,132 @@ func TestGetRequestMetadata_ZeroExpiryNeverRefreshes(t *testing.T) { assert.Equal(t, 1, src.calls(), "zero-expiry token should never be refreshed") } +// --- Backoff tests --- + +func TestGetRequestMetadata_BackoffSkipsRefreshDuringWindow(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + if n == 1 { + return &oauth2.Token{AccessToken: "stale", Expiry: time.Now().Add(-time.Minute)}, nil + } + return nil, fmt.Errorf("idp down") + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + _, err = provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + beforeCount := src.calls() + require.Equal(t, 2, beforeCount) + + ra := provider.(*refreshableAuth) + ra.mu.Lock() + ra.nextRetry = time.Now().Add(time.Minute) + ra.mu.Unlock() + + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer stale", md["authorization"]) + assert.Equal(t, beforeCount, src.calls(), "should not call Token() during backoff window") +} + +func TestGetRequestMetadata_BackoffResetsOnSuccess(t *testing.T) { + var callNum atomic.Int32 + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + n := callNum.Add(1) + switch n { + case 1: + return &oauth2.Token{AccessToken: "initial", Expiry: time.Now().Add(-time.Second)}, nil + case 2: + return nil, fmt.Errorf("fail once") + default: + return &oauth2.Token{AccessToken: "recovered", Expiry: time.Now().Add(time.Hour)}, nil + } + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + _, _ = provider.GetRequestMetadata(context.Background()) + _, _ = provider.GetRequestMetadata(context.Background()) + + ra := provider.(*refreshableAuth) + ra.mu.Lock() + ra.nextRetry = time.Time{} + ra.mu.Unlock() + + md, err := provider.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer recovered", md["authorization"]) + + ra.mu.Lock() + assert.Equal(t, time.Duration(0), ra.backoff, "backoff should reset after success") + assert.True(t, ra.nextRetry.IsZero(), "nextRetry should be zero after success") + ra.mu.Unlock() +} + +func TestGetRequestMetadata_BackoffCapsAt30s(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + return nil, fmt.Errorf("always fail") + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + ra := provider.(*refreshableAuth) + + for range 10 { + ra.mu.Lock() + ra.nextRetry = time.Time{} + ra.mu.Unlock() + + _, _ = provider.GetRequestMetadata(context.Background()) + } + + ra.mu.Lock() + assert.Equal(t, maxBackoff, ra.backoff, "backoff should cap at maxBackoff") + ra.mu.Unlock() +} + +func TestGetRequestMetadata_ConcurrentFailureBackoffNotOverIncremented(t *testing.T) { + src := &mockTokenSource{ + tokenFunc: func() (*oauth2.Token, error) { + time.Sleep(10 * time.Millisecond) + return nil, fmt.Errorf("idp down") + }, + } + provider, err := RefreshableToken(src, WithLeeway(0)) + require.NoError(t, err) + + const goroutines = 20 + var wg sync.WaitGroup + wg.Add(goroutines) + ready := make(chan struct{}) + for range goroutines { + go func() { + defer wg.Done() + <-ready + _, _ = provider.GetRequestMetadata(context.Background()) + }() + } + close(ready) + wg.Wait() + + ra := provider.(*refreshableAuth) + ra.mu.Lock() + assert.Equal(t, initialBackoff, ra.backoff, + "a single coalesced failure should set backoff to initialBackoff, not escalate") + ra.mu.Unlock() + assert.Equal(t, 1, src.calls(), "singleflight should coalesce to 1 call") +} + // --- benchmarks --- func BenchmarkGetRequestMetadata_CachedToken(b *testing.B) { diff --git a/sdk/go/openshell/v1/client.go b/sdk/go/openshell/v1/client.go index 85defcaaab..bc3ee43165 100644 --- a/sdk/go/openshell/v1/client.go +++ b/sdk/go/openshell/v1/client.go @@ -27,6 +27,8 @@ type ClientInterface interface { TCP() TCPInterface Config() ConfigInterface Policy() PolicyInterface + Workspaces() WorkspaceInterface + Inference() InferenceInterface Close() error } @@ -47,16 +49,18 @@ type Client struct { closeOnce sync.Once closeErr error - sandboxes SandboxInterface - providers ProviderInterface - services ServiceInterface - exec ExecInterface - files FileInterface - health HealthInterface - ssh SSHInterface - tcp TCPInterface - cfg ConfigInterface - policy PolicyInterface + sandboxes SandboxInterface + providers ProviderInterface + services ServiceInterface + exec ExecInterface + files FileInterface + health HealthInterface + ssh SSHInterface + tcp TCPInterface + cfg ConfigInterface + policy PolicyInterface + workspaces WorkspaceInterface + inference InferenceInterface } // NewClient creates a new SDK client connected to the given gateway. @@ -90,15 +94,17 @@ func NewClient(cfg Config) (*Client, error) { } c.sandboxes = newSandboxClient(conn) - c.providers = &stubProviders{} - c.services = &stubServices{} - c.exec = &stubExec{} - c.files = &stubFiles{} - c.health = &stubHealth{} - c.ssh = &stubSSH{} - c.tcp = &stubTCP{} - c.cfg = &stubConfig{} - c.policy = &stubPolicy{} + c.providers = newProviderClient(conn) + c.services = newServiceClient(conn) + c.exec = newExecClient(conn, c.sandboxes) + c.files = newFileClient(conn, c.sandboxes) + c.health = newHealthClient(conn) + c.ssh = newSSHClient(conn, c.sandboxes) + c.tcp = newTCPClient(conn, c.sandboxes, c.ssh) + c.cfg = newConfigClient(conn, c.sandboxes) + c.policy = newPolicyClient(conn) + c.workspaces = newWorkspaceClient(conn) + c.inference = newInferenceClient(conn) return c, nil } @@ -133,6 +139,12 @@ func (c *Client) Config() ConfigInterface { return c.cfg } // Policy returns the policy management sub-client. func (c *Client) Policy() PolicyInterface { return c.policy } +// Workspaces returns the workspace management sub-client. +func (c *Client) Workspaces() WorkspaceInterface { return c.workspaces } + +// Inference returns the inference route management sub-client. +func (c *Client) Inference() InferenceInterface { return c.inference } + // Close closes the underlying gRPC connection. Safe to call multiple times. func (c *Client) Close() error { c.closeOnce.Do(func() { diff --git a/sdk/go/openshell/v1/client_test.go b/sdk/go/openshell/v1/client_test.go index 7d6029b053..ce3ca860e2 100644 --- a/sdk/go/openshell/v1/client_test.go +++ b/sdk/go/openshell/v1/client_test.go @@ -30,6 +30,13 @@ func TestNewClient_ValidConfig(t *testing.T) { assert.NotNil(t, client.Exec()) assert.NotNil(t, client.Files()) assert.NotNil(t, client.Health()) + assert.NotNil(t, client.Services()) + assert.NotNil(t, client.SSH()) + assert.NotNil(t, client.TCP()) + assert.NotNil(t, client.Config()) + assert.NotNil(t, client.Policy()) + assert.NotNil(t, client.Workspaces()) + assert.NotNil(t, client.Inference()) err = client.Close() assert.NoError(t, err) diff --git a/sdk/go/openshell/v1/config.go b/sdk/go/openshell/v1/config.go index 58efb9bf2a..c135afc572 100644 --- a/sdk/go/openshell/v1/config.go +++ b/sdk/go/openshell/v1/config.go @@ -61,16 +61,7 @@ const ( // ConfigInterface defines operations for reading and updating gateway and // sandbox configuration. type ConfigInterface interface { - // GetSandbox retrieves the full configuration state for a sandbox, - // including policy, effective settings, and revision metadata. - // The sandbox is identified by name; the SDK resolves it to an ID internally. GetSandbox(ctx context.Context, workspace, sandboxName string) (*SandboxConfig, error) - - // GetGateway retrieves gateway-global settings. GetGateway(ctx context.Context) (*GatewayConfig, error) - - // Update applies a configuration mutation. For sandbox-scoped updates, - // set ConfigUpdate.Name to the sandbox name. For global-scoped updates, - // set ConfigUpdate.Global to true. Update(ctx context.Context, workspace string, update *ConfigUpdate) (*ConfigUpdateResult, error) } diff --git a/sdk/go/openshell/v1/config_client.go b/sdk/go/openshell/v1/config_client.go new file mode 100644 index 0000000000..e7086b9b6c --- /dev/null +++ b/sdk/go/openshell/v1/config_client.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "google.golang.org/grpc" +) + +type configClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface +} + +func newConfigClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *configClient { + return &configClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes} +} + +func (c *configClient) GetSandbox(ctx context.Context, workspace, sandboxName string) (*SandboxConfig, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := c.sandboxes.Get(ctx, workspace, sandboxName) + if err != nil { + return nil, err + } + + resp, err := c.client.GetSandboxConfig(ctx, &sbv1.GetSandboxConfigRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SandboxConfigFromProto(resp), nil +} + +func (c *configClient) GetGateway(ctx context.Context) (*GatewayConfig, error) { + resp, err := c.client.GetGatewayConfig(ctx, &sbv1.GetGatewayConfigRequest{}) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.GatewayConfigFromProto(resp), nil +} + +func (c *configClient) Update(ctx context.Context, workspace string, update *ConfigUpdate) (*ConfigUpdateResult, error) { + if update == nil { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: "update must not be nil", + } + } + req, convErr := converter.ConfigUpdateToProto(update) + if convErr != nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: convErr.Error()} + } + req.Workspace = workspace + resp, err := c.client.UpdateConfig(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ConfigUpdateResultFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/config_client_test.go b/sdk/go/openshell/v1/config_client_test.go new file mode 100644 index 0000000000..9fa8675ff2 --- /dev/null +++ b/sdk/go/openshell/v1/config_client_test.go @@ -0,0 +1,577 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "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" +) + +// --- Mock server for Config RPCs --- + +type mockConfigServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + + // Canned responses. + sandboxResp *sbv1.GetSandboxConfigResponse + gatewayResp *sbv1.GetGatewayConfigResponse + updateResp *pb.UpdateConfigResponse + + // Recorded requests. + lastSandboxReq *sbv1.GetSandboxConfigRequest + lastGatewayReq *sbv1.GetGatewayConfigRequest + lastUpdateReq *pb.UpdateConfigRequest + + // Inject errors. + sandboxErr error + gatewayErr error + updateErr error +} + +func newMockConfigServer() *mockConfigServer { + return &mockConfigServer{} +} + +func (s *mockConfigServer) GetSandboxConfig(_ context.Context, req *sbv1.GetSandboxConfigRequest) (*sbv1.GetSandboxConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastSandboxReq = req + if s.sandboxErr != nil { + return nil, s.sandboxErr + } + return s.sandboxResp, nil +} + +func (s *mockConfigServer) GetGatewayConfig(_ context.Context, req *sbv1.GetGatewayConfigRequest) (*sbv1.GetGatewayConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastGatewayReq = req + if s.gatewayErr != nil { + return nil, s.gatewayErr + } + return s.gatewayResp, nil +} + +func (s *mockConfigServer) UpdateConfig(_ context.Context, req *pb.UpdateConfigRequest) (*pb.UpdateConfigResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastUpdateReq = req + if s.updateErr != nil { + return nil, s.updateErr + } + return s.updateResp, nil +} + +// --- Test setup --- + +func setupConfigTest(t *testing.T, mock *mockConfigServer) (*configClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newConfigClient(conn, &stubSandboxResolver{}), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- GetSandbox tests --- + +func TestConfigGetSandbox(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{ + Policy: &sbv1.SandboxPolicy{ + Version: 4, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + Version: 3, + PolicyHash: "sha256:deadbeef", + ConfigRevision: 42, + PolicySource: sbv1.PolicySource_POLICY_SOURCE_SANDBOX, + GlobalPolicyVersion: 1, + ProviderEnvRevision: 7, + Settings: map[string]*sbv1.EffectiveSetting{ + "max_tokens": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 4096}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + "debug": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "default", "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, sc) + + // Verify request was forwarded with resolved ID (stubSandboxResolver returns "sb-"). + mock.mu.Lock() + assert.Equal(t, "sb-my-sandbox", mock.lastSandboxReq.GetSandboxId()) + mock.mu.Unlock() + + // Scalar fields. + assert.Equal(t, uint32(3), sc.PolicyVersion) + assert.Equal(t, "sha256:deadbeef", sc.PolicyHash) + assert.Equal(t, uint64(42), sc.ConfigRevision) + assert.Equal(t, PolicySource("sandbox"), sc.PolicySource) + assert.Equal(t, uint32(1), sc.GlobalPolicyVersion) + assert.Equal(t, uint64(7), sc.ProviderEnvRevision) + + // Typed SandboxPolicy. + require.NotNil(t, sc.Policy) + assert.Equal(t, uint32(4), sc.Policy.Version) + require.NotNil(t, sc.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, sc.Policy.Filesystem.ReadOnly) + + // Settings map. + require.Len(t, sc.Settings, 2) + + maxTok := sc.Settings["max_tokens"] + assert.Equal(t, SettingValueType("int"), maxTok.Value.Type) + assert.Equal(t, int64(4096), maxTok.Value.IntVal) + assert.Equal(t, SettingScope("sandbox"), maxTok.Scope) + + debug := sc.Settings["debug"] + assert.Equal(t, SettingValueType("bool"), debug.Value.Type) + assert.True(t, debug.Value.BoolVal) + assert.Equal(t, SettingScope("global"), debug.Scope) +} + +func TestConfigGetSandbox_DeepCopy(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{ + Version: 1, + Settings: map[string]*sbv1.EffectiveSetting{ + "key": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: []byte("original")}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "default", "sb1") + require.NoError(t, err) + + // Mutate the returned setting — should not affect future calls. + sc.Settings["key"] = EffectiveSetting{} + + sc2, err := client.GetSandbox(context.Background(), "default", "sb1") + require.NoError(t, err) + + // The server still returns the original value — verifies we're not + // sharing references between calls. + require.Contains(t, sc2.Settings, "key") + assert.Equal(t, []byte("original"), sc2.Settings["key"].Value.BytesVal) +} + +func TestConfigGetSandbox_Error(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxErr = status.Errorf(codes.Unavailable, "server unavailable") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "default", "my-sandbox") + + assert.Nil(t, sc) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Name-to-ID resolution tests --- + +func TestConfigGetSandbox_ResolvesNameToID(t *testing.T) { + mock := newMockConfigServer() + mock.sandboxResp = &sbv1.GetSandboxConfigResponse{Version: 1} + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + sc, err := client.GetSandbox(context.Background(), "default", "my-sandbox") + require.NoError(t, err) + require.NotNil(t, sc) + + // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name. + mock.mu.Lock() + assert.Equal(t, "sb-my-sandbox", mock.lastSandboxReq.GetSandboxId(), "GetSandbox should send resolved sandbox ID, not the name") + mock.mu.Unlock() +} + +func TestConfigGetSandbox_ResolutionError(t *testing.T) { + mock := newMockConfigServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newConfigClient(conn, resolver) + + sc, err := client.GetSandbox(context.Background(), "default", "nonexistent") + assert.Nil(t, sc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// --- GetGateway tests --- + +func TestConfigGetGateway(t *testing.T) { + mock := newMockConfigServer() + mock.gatewayResp = &sbv1.GetGatewayConfigResponse{ + SettingsRevision: 99, + Settings: map[string]*sbv1.SettingValue{ + "rate_limit": { + Value: &sbv1.SettingValue_IntValue{IntValue: 1000}, + }, + "motd": { + Value: &sbv1.SettingValue_StringValue{StringValue: "welcome"}, + }, + }, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + gc, err := client.GetGateway(context.Background()) + + require.NoError(t, err) + require.NotNil(t, gc) + + assert.Equal(t, uint64(99), gc.SettingsRevision) + require.Len(t, gc.Settings, 2) + + rl := gc.Settings["rate_limit"] + assert.Equal(t, SettingValueType("int"), rl.Type) + assert.Equal(t, int64(1000), rl.IntVal) + + motd := gc.Settings["motd"] + assert.Equal(t, SettingValueType("string"), motd.Type) + assert.Equal(t, "welcome", motd.StringVal) +} + +func TestConfigGetGateway_Error(t *testing.T) { + mock := newMockConfigServer() + mock.gatewayErr = status.Errorf(codes.Internal, "internal error") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + gc, err := client.GetGateway(context.Background()) + + assert.Nil(t, gc) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +// --- Update tests --- + +func TestConfigUpdate_SandboxScope(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 5, + PolicyHash: "sha256:cafe", + SettingsRevision: 10, + Deleted: false, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "max_tokens", + SettingValue: &SettingValue{ + Type: SettingValueInt, + IntVal: 8192, + }, + ExpectedResourceVersion: 4, + } + + result, err := client.Update(context.Background(), "default", update) + + require.NoError(t, err) + require.NotNil(t, result) + + assert.Equal(t, uint32(5), result.Version) + assert.Equal(t, "sha256:cafe", result.PolicyHash) + assert.Equal(t, uint64(10), result.SettingsRevision) + assert.False(t, result.Deleted) + + // Verify request was correctly converted. + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.GetName()) + assert.Equal(t, "max_tokens", req.GetSettingKey()) + assert.False(t, req.GetGlobal()) + assert.Equal(t, uint64(4), req.GetExpectedResourceVersion()) + assert.Equal(t, int64(8192), req.GetSettingValue().GetIntValue()) +} + +func TestConfigUpdate_GlobalScope(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + SettingsRevision: 20, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Global: true, + SettingKey: "global_flag", + SettingValue: &SettingValue{ + Type: SettingValueBool, + BoolVal: true, + }, + } + + result, err := client.Update(context.Background(), "default", update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint64(20), result.SettingsRevision) + + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + assert.True(t, req.GetGlobal()) + assert.Empty(t, req.GetName()) +} + +func TestConfigUpdate_DeleteSetting(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + SettingsRevision: 15, + Deleted: true, + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "deprecated_key", + DeleteSetting: true, + } + + result, err := client.Update(context.Background(), "default", update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Deleted) + assert.Equal(t, uint64(15), result.SettingsRevision) + + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + assert.True(t, req.GetDeleteSetting()) +} + +func TestConfigUpdate_WithPolicy(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 2, + PolicyHash: "sha256:newpolicy", + } + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + Policy: &types.SandboxPolicy{ + Version: 5, + Filesystem: &types.FilesystemPolicy{ + ReadOnly: []string{"/usr"}, + }, + }, + } + + result, err := client.Update(context.Background(), "default", update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(2), result.Version) + + // Verify the typed policy was converted and sent as proto. + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + + require.NotNil(t, req.GetPolicy()) + assert.Equal(t, uint32(5), req.GetPolicy().GetVersion()) + require.NotNil(t, req.GetPolicy().GetFilesystem()) + assert.Equal(t, []string{"/usr"}, req.GetPolicy().GetFilesystem().GetReadOnly()) +} + +func TestConfigUpdate_RejectsUnrepresentableMiddlewareConfigBeforeRPC(t *testing.T) { + mock := newMockConfigServer() + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + _, err := client.Update(context.Background(), "default", &ConfigUpdate{Policy: &SandboxPolicy{ + NetworkMiddlewares: map[string]types.NetworkMiddlewareConfig{ + "audit": {Config: map[string]any{"invalid": make(chan int)}}, + }, + }}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Nil(t, mock.lastUpdateReq) +} + +func TestConfigUpdate_Error(t *testing.T) { + mock := newMockConfigServer() + mock.updateErr = status.Errorf(codes.FailedPrecondition, "version mismatch") + + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "key", + ExpectedResourceVersion: 99, + } + + result, err := client.Update(context.Background(), "default", update) + + assert.Nil(t, result) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorConflict, se.Code) +} + +func TestConfigUpdate_NilUpdate(t *testing.T) { + mock := newMockConfigServer() + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + result, err := client.Update(context.Background(), "default", nil) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestConfigUpdate_MergeOperationsAccepted(t *testing.T) { + mock := newMockConfigServer() + mock.updateResp = &pb.UpdateConfigResponse{ + Version: 3, + PolicyHash: "abc123", + } + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + MergeOperations: []types.PolicyMergeOperation{{RemoveRule: &types.RemoveNetworkRule{RuleName: "test"}}}, + } + + result, err := client.Update(context.Background(), "default", update) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(3), result.Version) + assert.Equal(t, "abc123", result.PolicyHash) + + // Verify the merge operations were serialized in the proto request + mock.mu.Lock() + req := mock.lastUpdateReq + mock.mu.Unlock() + require.NotNil(t, req) + assert.NotEmpty(t, req.GetMergeOperations(), "MergeOperations should be serialized to proto") +} + +func TestConfigUpdate_ErrorConflict(t *testing.T) { + mock := newMockConfigServer() + mock.updateErr = status.Error(codes.Aborted, "resource version conflict") + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + update := &ConfigUpdate{ + Name: "my-sandbox", + ExpectedResourceVersion: 5, + } + + result, err := client.Update(context.Background(), "default", update) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsConflict(err)) +} + +func TestConfigGetSandbox_EmptySandboxName(t *testing.T) { + mock := newMockConfigServer() + client, cleanup := setupConfigTest(t, mock) + defer cleanup() + + cfg, err := client.GetSandbox(context.Background(), "default", "") + assert.Nil(t, cfg) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} diff --git a/sdk/go/openshell/v1/grpc_errors.go b/sdk/go/openshell/v1/context_errors.go similarity index 82% rename from sdk/go/openshell/v1/grpc_errors.go rename to sdk/go/openshell/v1/context_errors.go index 4c31351167..3c934b3bac 100644 --- a/sdk/go/openshell/v1/grpc_errors.go +++ b/sdk/go/openshell/v1/context_errors.go @@ -1,8 +1,6 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -// Package v1 provides the OpenShell SDK client. -// gRPC error conversion is handled by the internal/converter package. package v1 import "context" diff --git a/sdk/go/openshell/v1/context_errors_test.go b/sdk/go/openshell/v1/context_errors_test.go new file mode 100644 index 0000000000..442a3ee32f --- /dev/null +++ b/sdk/go/openshell/v1/context_errors_test.go @@ -0,0 +1,49 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestContextError_Nil(t *testing.T) { + result := contextError(nil) + assert.Nil(t, result) +} + +func TestContextError_DeadlineExceeded(t *testing.T) { + result := contextError(context.DeadlineExceeded) + + require.Error(t, result) + var se *StatusError + require.True(t, errors.As(result, &se)) + assert.Equal(t, ErrorDeadlineExceeded, se.Code) + assert.True(t, errors.Is(result, context.DeadlineExceeded)) +} + +func TestContextError_Canceled(t *testing.T) { + result := contextError(context.Canceled) + + require.Error(t, result) + var se *StatusError + require.True(t, errors.As(result, &se)) + assert.Equal(t, ErrorCancelled, se.Code) + assert.True(t, errors.Is(result, context.Canceled)) +} + +func TestContextError_Default(t *testing.T) { + orig := errors.New("unexpected context error") + result := contextError(orig) + + require.Error(t, result) + var se *StatusError + require.True(t, errors.As(result, &se)) + assert.Equal(t, ErrorInternal, se.Code) + assert.True(t, errors.Is(result, orig)) +} diff --git a/sdk/go/openshell/v1/doc.go b/sdk/go/openshell/v1/doc.go index dd78d81897..d088ae68fc 100644 --- a/sdk/go/openshell/v1/doc.go +++ b/sdk/go/openshell/v1/doc.go @@ -5,7 +5,7 @@ // // The SDK follows the Kubernetes client-go sub-client pattern: a single Client // provides typed accessors for each resource domain (Sandboxes, Providers, Exec, -// Files, Health, Services, SSH, TCP, Config). All operations accept a context.Context and return idiomatic +// Files, Health, Services, SSH, TCP, Config, Policy, Workspaces, Inference). All operations accept a context.Context and return idiomatic // Go types. Proto-generated types never appear in the public API. // // # Quick Start @@ -34,7 +34,7 @@ // log.Fatal(err) // } // -// # Command Execution (available in a future release) +// # Command Execution // // result, err := client.Exec().Run(ctx, "default", sandbox.Name, []string{"echo", "hello"}, v1.ExecOptions{}) // if err != nil { @@ -76,7 +76,7 @@ // } // // channel closes automatically after Ready or Error // -// # Service Exposure (available in a future release) +// # Service Exposure // // Expose an HTTP service running inside a sandbox and retrieve its public URL: // @@ -94,7 +94,7 @@ // fmt.Printf(" %s → port %d (URL: %s)\n", ep.ServiceName, ep.TargetPort, ep.URL) // } // -// # Provider Profiles (available in a future release) +// # Provider Profiles // // List available provider profiles and import new ones: // @@ -119,7 +119,7 @@ // fmt.Printf("[%s] %s: %s\n", d.Severity, d.Field, d.Message) // } // -// # Credential Refresh (available in a future release) +// # Credential Refresh // // Configure gateway-owned credential refresh for a provider: // @@ -192,7 +192,7 @@ // "x-proxy-key": "proxy-secret", // }) // -// # SSH Session Management (available in a future release) +// # SSH Session Management // // Create an SSH session for a sandbox and use the returned connection details. // Note: CreateSession accepts a sandbox ID, not a name. For name-based access @@ -213,7 +213,7 @@ // } // fmt.Printf("Session revoked: %v\n", revoked) // -// # TCP Port Forwarding (available in a future release) +// # TCP Port Forwarding // // Forward a local connection to a port inside a sandbox: // @@ -242,7 +242,7 @@ // v1.WithForwardServiceID("billing-db"), // ) // -// # SSH Tunneling (available in a future release) +// # SSH Tunneling // // Create an SSH tunnel to a sandbox port in a single call. Tunnel combines // session creation, TCP forwarding with an SSH relay target, and automatic @@ -300,7 +300,7 @@ // }, // }, nil) // -// Replace the full policy at runtime via configuration update (available in a future release): +// Replace the full policy at runtime via configuration update: // // result, err := client.Config().Update(ctx, "default", &v1.ConfigUpdate{ // Name: "secure-sandbox", @@ -312,7 +312,7 @@ // }, // }) // -// Read a policy back from revision history (available in a future release): +// Read a policy back from revision history: // // revisions, err := client.Policy().List(ctx, "default") // if err != nil { @@ -324,7 +324,93 @@ // } // } // -// # Configuration Management (available in a future release) +// # Global Policy +// +// List gateway-global policy revisions (no sandbox name or workspace needed): +// +// revisions, err := client.Policy().List(ctx, "", v1.WithListGlobal(true)) +// if err != nil { +// log.Fatal(err) +// } +// for _, rev := range revisions { +// fmt.Printf("Global v%d: %s\n", rev.Version, rev.Status) +// } +// +// Get the status of a specific global policy version: +// +// status, err := client.Policy().GetStatus(ctx, "", "", +// v1.WithStatusGlobal(true), v1.WithVersion(3), +// ) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Version %d status: %s\n", status.Revision.Version, status.Revision.Status) +// +// # Workspace Management +// +// Create and manage workspaces for multi-tenant resource isolation: +// +// ws, err := client.Workspaces().Create(ctx, "team-alpha", map[string]string{ +// "team": "alpha", +// "env": "production", +// }) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Workspace %s created (phase: %s)\n", ws.Name, ws.Phase) +// +// workspaces, err := client.Workspaces().List(ctx) +// if err != nil { +// log.Fatal(err) +// } +// for _, w := range workspaces { +// fmt.Printf(" %s (phase: %s)\n", w.Name, w.Phase) +// } +// +// # Workspace Members +// +// Manage workspace membership with role-based access: +// +// member, err := client.Workspaces().AddMember(ctx, "team-alpha", +// "alice@example.com", v1.WorkspaceRoleAdmin) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Added %s as %s\n", member.PrincipalSubject, member.Role) +// +// members, err := client.Workspaces().ListMembers(ctx, "team-alpha") +// if err != nil { +// log.Fatal(err) +// } +// for _, m := range members { +// fmt.Printf(" %s (%s)\n", m.PrincipalSubject, m.Role) +// } +// +// # Gateway Info +// +// Query gateway metadata and compute driver capabilities: +// +// info, err := client.Health().GetGatewayInfo(ctx) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Gateway %s (status: %s)\n", info.Version, info.Status) +// for _, d := range info.ComputeDrivers { +// fmt.Printf(" Driver: %s %s\n", d.DriverName, d.DriverVersion) +// } +// +// # Current User +// +// Determine the identity of the authenticated caller: +// +// user, err := client.Health().GetCurrentUser(ctx) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Logged in as %s (%s)\n", user.DisplayName, user.Subject) +// fmt.Printf("Roles: %v, Scopes: %v\n", user.Roles, user.Scopes) +// +// # Configuration Management // // Read sandbox and gateway configuration, and update settings: // @@ -355,4 +441,31 @@ // log.Fatal(err) // } // fmt.Printf("New settings revision: %d\n", result.SettingsRevision) +// +// # Inference Route Management +// +// Configure workspace-scoped inference routing to control how inference +// requests are forwarded to upstream providers: +// +// route, err := client.Inference().SetRoute(ctx, "my-workspace", &v1.InferenceRouteConfig{ +// ProviderName: "openai", +// ModelID: "gpt-4", +// RouteName: "", // empty string = default route +// TimeoutSecs: 120, +// }) +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Route v%d: %s/%s\n", route.Version, route.ProviderName, route.ModelID) +// +// route, err = client.Inference().GetRoute(ctx, "my-workspace", "") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Provider: %s, Model: %s\n", route.ProviderName, route.ModelID) +// +// err = client.Inference().DeleteRoute(ctx, "my-workspace", "") +// if err != nil { +// log.Fatal(err) +// } package v1 diff --git a/sdk/go/openshell/v1/edge/cloudflare.go b/sdk/go/openshell/v1/edge/cloudflare.go new file mode 100644 index 0000000000..5363fe07f4 --- /dev/null +++ b/sdk/go/openshell/v1/edge/cloudflare.go @@ -0,0 +1,29 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "errors" + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +// CloudflareAccess returns an AuthProvider that adds Cloudflare Access +// headers to every RPC. It sets: +// - cf-access-jwt-assertion: the edge JWT token +// - cookie: CF_Authorization= +// +// The edgeToken authenticates with the Cloudflare Access edge proxy. +// Returns an error if baseAuth is nil or edgeToken is empty. +func CloudflareAccess(baseAuth v1.AuthProvider, edgeToken string) (v1.AuthProvider, error) { + if edgeToken == "" { + return nil, errors.New("edge token must not be empty") + } + + return v1.WithExtraHeaders(baseAuth, map[string]string{ + "cf-access-jwt-assertion": edgeToken, + "cookie": fmt.Sprintf("CF_Authorization=%s", edgeToken), + }) +} diff --git a/sdk/go/openshell/v1/edge/cloudflare_test.go b/sdk/go/openshell/v1/edge/cloudflare_test.go new file mode 100644 index 0000000000..902e599d69 --- /dev/null +++ b/sdk/go/openshell/v1/edge/cloudflare_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "context" + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCloudflareAccess_ValidToken(t *testing.T) { + base := v1.StaticToken("my-token") + auth, err := CloudflareAccess(base, "cf-edge-jwt-xxx") + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // Base auth header preserved. + assert.Equal(t, "Bearer my-token", md["authorization"]) + + // Cloudflare-specific headers present. + assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"]) + assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"]) +} + +func TestCloudflareAccess_EmptyToken(t *testing.T) { + base := v1.StaticToken("my-token") + _, err := CloudflareAccess(base, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "edge token") +} + +func TestCloudflareAccess_NilBase(t *testing.T) { + _, err := CloudflareAccess(nil, "cf-edge-jwt-xxx") + require.Error(t, err) + assert.Contains(t, err.Error(), "base") +} + +func TestCloudflareAccess_WithNoAuth(t *testing.T) { + auth, err := CloudflareAccess(v1.NoAuth(), "cf-edge-jwt-xxx") + require.NoError(t, err) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + + // NoAuth provides no base metadata; only CF headers should appear. + assert.Equal(t, "cf-edge-jwt-xxx", md["cf-access-jwt-assertion"]) + assert.Equal(t, "CF_Authorization=cf-edge-jwt-xxx", md["cookie"]) +} + +func TestCloudflareAccess_RequireTransportSecurity_Delegates(t *testing.T) { + tests := []struct { + name string + base v1.AuthProvider + expected bool + }{ + { + name: "delegates to NoAuth (false)", + base: v1.NoAuth(), + expected: false, + }, + { + name: "delegates to StaticToken (true)", + base: v1.StaticToken("tok"), + expected: true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + auth, err := CloudflareAccess(tt.base, "cf-edge-jwt-xxx") + require.NoError(t, err) + assert.Equal(t, tt.expected, auth.RequireTransportSecurity()) + }) + } +} + +func TestCloudflareAccess_TokenNotInError(t *testing.T) { + // Verify the error for empty token does not leak actual token values. + _, err := CloudflareAccess(v1.StaticToken("s3cr3t-val"), "") + require.Error(t, err) + // The error should mention the parameter name, not any token value. + assert.NotContains(t, err.Error(), "s3cr3t-val") +} diff --git a/sdk/go/openshell/v1/edge/doc.go b/sdk/go/openshell/v1/edge/doc.go new file mode 100644 index 0000000000..92fd9b1f9f --- /dev/null +++ b/sdk/go/openshell/v1/edge/doc.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package edge provides utilities for connecting to OpenShell gateways +// through edge proxies such as Cloudflare Access. It includes convenience +// constructors for common edge auth patterns and a WebSocket tunnel proxy +// for gRPC transport through HTTP/1.1-only proxies. +// +// # Cloudflare Access +// +// CloudflareAccess wraps any AuthProvider with the headers required by +// Cloudflare Access (cf-access-jwt-assertion and CF_Authorization cookie). +// The edge token is typically a service token or application token obtained +// from Cloudflare: +// +// base := v1.StaticToken("my-gateway-token") +// auth, err := edge.CloudflareAccess(base, os.Getenv("CF_ACCESS_TOKEN")) +// if err != nil { +// log.Fatal(err) +// } +// client, err := v1.NewClient(v1.Config{ +// Address: "gateway.example.com:443", +// Auth: auth, +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// CloudflareAccess composes with any auth provider, including RefreshableToken +// for automatic token refresh: +// +// tokenSource := oauth2Config.TokenSource(ctx, initialToken) +// refreshAuth, err := v1.RefreshableToken(tokenSource) +// if err != nil { +// log.Fatal(err) +// } +// auth, err := edge.CloudflareAccess(refreshAuth, cfToken) +// if err != nil { +// log.Fatal(err) +// } +// +// # WebSocket Tunnel +// +// TunnelProxy bridges gRPC connections over a WebSocket tunnel for edge +// proxies that reject standard HTTP/2 POST requests. The tunnel carries +// its own edge token for proxy authentication, independent of the +// application-level auth provider. +// +// Create a tunnel proxy pointed at the gateway, then dial the proxy's +// local address from the gRPC client: +// +// tunnel, err := edge.NewTunnelProxy( +// "wss://gateway.example.com/ws", +// os.Getenv("CF_ACCESS_TOKEN"), +// ) +// if err != nil { +// log.Fatal(err) +// } +// defer tunnel.Close() +// +// auth := v1.StaticToken("my-gateway-token") +// client, err := v1.NewClient(v1.Config{ +// Address: tunnel.Addr(), +// Auth: auth, +// TLS: &v1.TLSConfig{Insecure: true}, // local tunnel +// }) +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Use functional options to configure TLS, logging, and close timeout: +// +// tunnel, err := edge.NewTunnelProxy( +// "wss://gateway.example.com/ws", +// cfToken, +// edge.WithTunnelTLS(&tls.Config{RootCAs: customCertPool}), +// edge.WithTunnelLogger(myLogger), +// edge.WithCloseTimeout(10*time.Second), +// ) +// +// Close drains in-flight connections gracefully. If draining exceeds the +// configured timeout (default 5 seconds), remaining connections are +// force-closed: +// +// err := tunnel.Close() // safe to call multiple times +package edge diff --git a/sdk/go/openshell/v1/edge/tunnel.go b/sdk/go/openshell/v1/edge/tunnel.go new file mode 100644 index 0000000000..72cfedceb0 --- /dev/null +++ b/sdk/go/openshell/v1/edge/tunnel.go @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "context" + "crypto/tls" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/url" + "sync" + "time" + + "github.com/coder/websocket" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const defaultCloseTimeout = 5 * time.Second + +// tunnelConfig holds configuration set by TunnelOption functions. +type tunnelConfig struct { + logger types.Logger + tlsConfig *tls.Config + closeTimeout time.Duration +} + +// TunnelOption configures TunnelProxy behavior. +type TunnelOption func(*tunnelConfig) + +// WithTunnelLogger sets the structured logger for tunnel events. +func WithTunnelLogger(l types.Logger) TunnelOption { + return func(c *tunnelConfig) { + c.logger = l + } +} + +// WithTunnelTLS sets TLS configuration for the WebSocket connection (wss://). +func WithTunnelTLS(cfg *tls.Config) TunnelOption { + return func(c *tunnelConfig) { + c.tlsConfig = cfg + } +} + +// WithCloseTimeout sets the maximum time Close waits for in-flight +// connections to drain before force-closing. Default is 5 seconds. +func WithCloseTimeout(d time.Duration) TunnelOption { + return func(c *tunnelConfig) { + c.closeTimeout = d + } +} + +// TunnelProxy bridges gRPC connections over a WebSocket tunnel. +// The gRPC client dials TunnelProxy.Addr() instead of the remote gateway. +// Each accepted connection spawns a goroutine that dials the gateway over +// WebSocket and copies data bidirectionally. +type TunnelProxy struct { + listener net.Listener + gatewayURL string + edgeToken string + logger types.Logger + closeTimeout time.Duration + httpClient *http.Client + + ctx context.Context + cancel context.CancelFunc + wg sync.WaitGroup + mu sync.Mutex + closing bool + closeOnce sync.Once + closeErr error +} + +// NewTunnelProxy creates a tunnel proxy that forwards TCP connections +// through a WebSocket connection to gatewayURL. The edgeToken authenticates +// with the edge proxy via Cloudflare Access headers on the WebSocket +// handshake. +// +// Returns error if gatewayURL is empty or invalid, or if edgeToken is empty. +func NewTunnelProxy(gatewayURL, edgeToken string, opts ...TunnelOption) (*TunnelProxy, error) { + if gatewayURL == "" { + return nil, errors.New("gateway URL must not be empty") + } + if edgeToken == "" { + return nil, errors.New("edge token must not be empty") + } + + // Validate the URL parses correctly. + u, err := url.Parse(gatewayURL) + if err != nil { + return nil, fmt.Errorf("invalid gateway URL: %w", err) + } + if u.Scheme != "ws" && u.Scheme != "wss" { + return nil, fmt.Errorf("gateway URL must use ws:// or wss:// scheme, got %q", u.Scheme) + } + if u.Host == "" { + return nil, errors.New("gateway URL must include a host") + } + if u.Scheme == "ws" && !isLoopbackHost(u.Hostname()) { + return nil, errors.New("gateway URL with an edge token must use wss:// (ws:// is allowed only for loopback hosts)") + } + + cfg := tunnelConfig{ + closeTimeout: defaultCloseTimeout, + } + for _, o := range opts { + o(&cfg) + } + + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + return nil, fmt.Errorf("listen: %w", err) + } + + ctx, cancel := context.WithCancel(context.Background()) + + var httpClient *http.Client + if cfg.tlsConfig != nil { + httpClient = &http.Client{ + Transport: &http.Transport{ + TLSClientConfig: cfg.tlsConfig, + }, + } + } + + tp := &TunnelProxy{ + listener: listener, + gatewayURL: gatewayURL, + edgeToken: edgeToken, + logger: cfg.logger, + closeTimeout: cfg.closeTimeout, + httpClient: httpClient, + ctx: ctx, + cancel: cancel, + } + + // Start the accept loop. + tp.wg.Add(1) + go tp.acceptLoop() + + return tp, nil +} + +func isLoopbackHost(host string) bool { + if host == "localhost" { + return true + } + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} + +// Addr returns the local address the gRPC client should dial. +func (tp *TunnelProxy) Addr() string { + return tp.listener.Addr().String() +} + +// Close drains in-flight connections (up to the configured timeout, +// default 5s) then force-closes any remaining connections. All goroutines +// are cleaned up. Safe to call multiple times; the second and subsequent +// calls return immediately. +func (tp *TunnelProxy) Close() error { + tp.closeOnce.Do(func() { + tp.mu.Lock() + tp.closing = true + tp.mu.Unlock() + + // Stop accepting new connections. + tp.closeErr = tp.listener.Close() + + // Wait for in-flight connections to drain, with a timeout. + done := make(chan struct{}) + go func() { + tp.wg.Wait() + close(done) + }() + + select { + case <-done: + // All goroutines drained cleanly. + case <-time.After(tp.closeTimeout): + // Timeout reached; cancel all bridge contexts to force-close. + if tp.logger != nil { + tp.logger.Info("tunnel close timeout reached, force-closing") + } + tp.cancel() + <-done + } + // Always cancel to release the context tree. + tp.cancel() + }) + return tp.closeErr +} + +// acceptLoop runs in a goroutine. It accepts local TCP connections and +// spawns a bridge goroutine for each one. +func (tp *TunnelProxy) acceptLoop() { + defer tp.wg.Done() + + for { + conn, err := tp.listener.Accept() + if err != nil { + if errors.Is(err, net.ErrClosed) { + return + } + if tp.logger != nil { + tp.logger.Error(err, "tunnel accept error") + } + time.Sleep(10 * time.Millisecond) + continue + } + + tp.mu.Lock() + if tp.closing { + tp.mu.Unlock() + _ = conn.Close() + return + } + tp.wg.Add(1) + tp.mu.Unlock() + + if tp.logger != nil { + tp.logger.Debug("tunnel connection accepted", "remote", conn.RemoteAddr().String()) + } + + go tp.bridge(conn) + } +} + +// bridge dials the gateway over WebSocket and copies data bidirectionally +// between the local TCP connection and the WebSocket connection. +func (tp *TunnelProxy) bridge(local net.Conn) { + defer tp.wg.Done() + defer func() { _ = local.Close() }() + + ctx, cancel := context.WithCancel(tp.ctx) + defer cancel() + + // Build WebSocket dial options with edge auth headers. + dialOpts := &websocket.DialOptions{ + HTTPHeader: http.Header{ + "cf-access-jwt-assertion": []string{tp.edgeToken}, + "cookie": []string{fmt.Sprintf("CF_Authorization=%s", tp.edgeToken)}, + }, + } + if tp.httpClient != nil { + dialOpts.HTTPClient = tp.httpClient + } + + dialCtx, dialCancel := context.WithTimeout(ctx, 10*time.Second) + defer dialCancel() + + wsConn, _, err := websocket.Dial(dialCtx, tp.gatewayURL, dialOpts) + if err != nil { + if tp.logger != nil { + tp.logger.Error(err, "tunnel websocket dial failed") + } + return + } + defer func() { _ = wsConn.CloseNow() }() + + // Set a generous read limit for gRPC frames. + wsConn.SetReadLimit(64 * 1024 * 1024) // 64 MiB + + // Convert the WebSocket connection to a net.Conn for bidirectional I/O. + remote := websocket.NetConn(ctx, wsConn, websocket.MessageBinary) + + // Bidirectional copy. + done := make(chan struct{}, 2) + + // Local -> Remote (WebSocket) + go func() { + _, _ = io.Copy(remote, local) + done <- struct{}{} + }() + + // Remote (WebSocket) -> Local + go func() { + _, _ = io.Copy(local, remote) + done <- struct{}{} + }() + + // Wait for one direction to finish, then tear down both. + <-done + cancel() + _ = local.Close() + <-done + + if tp.logger != nil { + tp.logger.Debug("tunnel bridge closed") + } +} diff --git a/sdk/go/openshell/v1/edge/tunnel_test.go b/sdk/go/openshell/v1/edge/tunnel_test.go new file mode 100644 index 0000000000..4a2215e187 --- /dev/null +++ b/sdk/go/openshell/v1/edge/tunnel_test.go @@ -0,0 +1,500 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package edge + +import ( + "crypto/tls" + "crypto/x509" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "runtime" + "slices" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// echoWSHandler accepts a WebSocket connection and echoes every binary +// message back to the sender until the client disconnects. +func echoWSHandler(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + defer func() { _ = conn.CloseNow() }() + + ctx := r.Context() + for { + typ, data, err := conn.Read(ctx) + if err != nil { + return + } + if err := conn.Write(ctx, typ, data); err != nil { + return + } + } +} + +// startEchoServer starts an HTTP test server that upgrades connections to +// WebSocket and echoes binary messages. Returns the server and its ws:// URL. +func startEchoServer(t *testing.T) (*httptest.Server, string) { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(echoWSHandler)) + t.Cleanup(srv.Close) + // Convert http://host:port to ws://host:port. + wsURL := "ws" + srv.URL[len("http"):] + return srv, wsURL +} + +// startTLSEchoServer starts a TLS HTTP test server. Returns the server, +// its wss:// URL, and a tls.Config that trusts the server's certificate. +func startTLSEchoServer(t *testing.T) (*httptest.Server, string, *tls.Config) { + t.Helper() + srv := httptest.NewTLSServer(http.HandlerFunc(echoWSHandler)) + t.Cleanup(srv.Close) + wssURL := "wss" + srv.URL[len("https"):] + + certPool := x509.NewCertPool() + certPool.AddCert(srv.Certificate()) + tlsCfg := &tls.Config{ + RootCAs: certPool, + } + return srv, wssURL, tlsCfg +} + +// testLogger captures log messages for assertions. +type testLogger struct { + mu sync.Mutex + messages []string +} + +func (l *testLogger) Debug(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "DEBUG: "+msg) +} + +func (l *testLogger) Info(msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "INFO: "+msg) +} + +func (l *testLogger) Error(_ error, msg string, _ ...any) { + l.mu.Lock() + defer l.mu.Unlock() + l.messages = append(l.messages, "ERROR: "+msg) +} + +func (l *testLogger) Messages() []string { + l.mu.Lock() + defer l.mu.Unlock() + cp := make([]string, len(l.messages)) + copy(cp, l.messages) + return cp +} + +// --- Test: NewTunnelProxy creation --- + +func TestNewTunnelProxy_ValidURL(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "edge-token-123") + require.NoError(t, err) + require.NotNil(t, tp) + defer func() { _ = tp.Close() }() + + // Addr() must return a non-empty, dialable address. + addr := tp.Addr() + assert.NotEmpty(t, addr) + + // Verify the address is dialable. + conn, err := net.DialTimeout("tcp", addr, 2*time.Second) + require.NoError(t, err) + _ = conn.Close() +} + +func TestNewTunnelProxy_EmptyURL(t *testing.T) { + _, err := NewTunnelProxy("", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway URL") +} + +func TestNewTunnelProxy_InvalidURL(t *testing.T) { + _, err := NewTunnelProxy("://bad-url", "edge-token-123") + require.Error(t, err) +} + +func TestNewTunnelProxy_WrongScheme(t *testing.T) { + _, err := NewTunnelProxy("http://gateway.example.com", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "ws:// or wss://") +} + +func TestNewTunnelProxy_RejectsCredentialBearingRemoteWS(t *testing.T) { + _, err := NewTunnelProxy("ws://gateway.example.com/tunnel", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "wss://") +} + +func TestNewTunnelProxy_EmptyHost(t *testing.T) { + _, err := NewTunnelProxy("ws://", "edge-token-123") + require.Error(t, err) + assert.Contains(t, err.Error(), "must include a host") +} + +func TestNewTunnelProxy_EmptyEdgeToken(t *testing.T) { + _, wsURL := startEchoServer(t) + + _, err := NewTunnelProxy(wsURL, "") + require.Error(t, err) + assert.Contains(t, err.Error(), "edge token") +} + +func TestNewTunnelProxy_TokenNotInError(t *testing.T) { + // Verify error messages do not leak the edge token. + _, err := NewTunnelProxy("", "super-secret-token-abc") + require.Error(t, err) + assert.NotContains(t, err.Error(), "super-secret-token-abc") +} + +// --- Test: Addr --- + +func TestTunnelProxy_Addr_Dialable(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + addr := tp.Addr() + host, port, err := net.SplitHostPort(addr) + require.NoError(t, err) + assert.NotEmpty(t, host) + assert.NotEmpty(t, port) +} + +// --- Test: Close on unused proxy --- + +func TestTunnelProxy_Close_Unused(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Close immediately without any connections should return nil. + err = tp.Close() + assert.NoError(t, err) +} + +// --- Test: Close drains in-flight connections --- + +func TestTunnelProxy_Close_DrainsInFlight(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(5*time.Second)) + require.NoError(t, err) + + // Establish a connection through the tunnel. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + + // Send data through the tunnel and verify echo. + testData := []byte("hello tunnel") + _, err = conn.Write(testData) + require.NoError(t, err) + + // Give the tunnel time to relay. + time.Sleep(100 * time.Millisecond) + + // Close the client connection first so the bridge goroutine can drain. + _ = conn.Close() + + // Now close the tunnel; should drain cleanly. + err = tp.Close() + assert.NoError(t, err) +} + +// --- Test: Close force-closes after timeout --- + +func TestTunnelProxy_Close_ForceClosesAfterTimeout(t *testing.T) { + // Use a slow handler that holds connections open. + slowHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + wsConn, err := websocket.Accept(w, r, &websocket.AcceptOptions{ + InsecureSkipVerify: true, + }) + if err != nil { + return + } + defer func() { _ = wsConn.CloseNow() }() + // Hold the connection open for a long time. + ctx := r.Context() + select { + case <-ctx.Done(): + case <-time.After(30 * time.Second): + } + }) + srv := httptest.NewServer(slowHandler) + defer srv.Close() + wsURL := "ws" + srv.URL[len("http"):] + + // Use a very short close timeout and a logger to verify timeout logging. + logger := &testLogger{} + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(200*time.Millisecond), WithTunnelLogger(logger)) + require.NoError(t, err) + + // Establish a connection that will be held open by the slow handler. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Write something to trigger the WebSocket dial. + _, _ = conn.Write([]byte("trigger")) + + // Give the tunnel time to establish the bridge. + time.Sleep(100 * time.Millisecond) + + // Close should force-close after the short timeout, not hang. + start := time.Now() + err = tp.Close() + elapsed := time.Since(start) + + // Should complete within a reasonable time (timeout + margin). + assert.Less(t, elapsed, 2*time.Second, "Close should not hang beyond timeout") + // err may or may not be nil depending on force-close; we don't assert on it. + _ = err + + // Verify the timeout was logged. + msgs := logger.Messages() + assert.True(t, slices.Contains(msgs, "INFO: tunnel close timeout reached, force-closing"), + "expected timeout log message, got: %v", msgs) +} + +// --- Test: Concurrent Close is safe --- + +func TestTunnelProxy_Close_ConcurrentSafe(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Call Close concurrently from multiple goroutines. + var wg sync.WaitGroup + errs := make([]error, 10) + for i := range errs { + wg.Add(1) + go func(idx int) { + defer wg.Done() + errs[idx] = tp.Close() + }(i) + } + wg.Wait() + + // All calls should succeed without panic. + for _, e := range errs { + assert.NoError(t, e) + } +} + +// --- Test: Goroutine cleanup --- + +func TestTunnelProxy_GoroutineCleanup(t *testing.T) { + _, wsURL := startEchoServer(t) + + // Record baseline goroutine count. + runtime.GC() + time.Sleep(50 * time.Millisecond) + baseline := runtime.NumGoroutine() + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + + // Open several connections. + conns := make([]net.Conn, 5) + for i := range conns { + c, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + _, _ = fmt.Fprintf(c, "msg-%d", i) + conns[i] = c + } + + // Let bridges establish. + time.Sleep(100 * time.Millisecond) + + // Close all client connections. + for _, c := range conns { + _ = c.Close() + } + + // Close the tunnel. + err = tp.Close() + require.NoError(t, err) + + // Wait for goroutines to wind down. + time.Sleep(200 * time.Millisecond) + runtime.GC() + + // Goroutine count should return to near baseline. + // Allow a small margin for runtime goroutines. + final := runtime.NumGoroutine() + assert.LessOrEqual(t, final, baseline+3, + "goroutine leak: baseline=%d, final=%d", baseline, final) +} + +// --- Test: Concurrent streams --- + +func TestTunnelProxy_ConcurrentStreams(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + const streamCount = 10 + var wg sync.WaitGroup + errs := make(chan error, streamCount) + + for i := range streamCount { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + if err != nil { + errs <- fmt.Errorf("stream %d dial: %w", idx, err) + return + } + defer func() { _ = conn.Close() }() + + msg := fmt.Sprintf("stream-%d-data", idx) + _, err = conn.Write([]byte(msg)) + if err != nil { + errs <- fmt.Errorf("stream %d write: %w", idx, err) + return + } + + // Read echo response. + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + n, err := io.ReadFull(conn, buf) + if err != nil { + errs <- fmt.Errorf("stream %d read: %w (got %d bytes)", idx, err, n) + return + } + + if string(buf) != msg { + errs <- fmt.Errorf("stream %d: expected %q, got %q", idx, msg, string(buf)) + } + }(i) + } + + wg.Wait() + close(errs) + + for err := range errs { + t.Error(err) + } +} + +// --- Test: TLS option --- + +func TestTunnelProxy_TLSOption(t *testing.T) { + _, wssURL, tlsCfg := startTLSEchoServer(t) + + tp, err := NewTunnelProxy(wssURL, "tok", WithTunnelTLS(tlsCfg)) + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + // Verify we can communicate through the TLS tunnel. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + msg := []byte("tls-echo-test") + _, err = conn.Write(msg) + require.NoError(t, err) + + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + assert.Equal(t, msg, buf) +} + +// --- Test: Logger option --- + +func TestTunnelProxy_LoggerOption(t *testing.T) { + _, wsURL := startEchoServer(t) + + logger := &testLogger{} + tp, err := NewTunnelProxy(wsURL, "tok", WithTunnelLogger(logger)) + require.NoError(t, err) + + // Open a connection to trigger log events. + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + + _, _ = conn.Write([]byte("log-test")) + + // Give the tunnel time to process. + time.Sleep(200 * time.Millisecond) + + _ = conn.Close() + + err = tp.Close() + require.NoError(t, err) + + // Logger should have received at least one message. + msgs := logger.Messages() + assert.NotEmpty(t, msgs, "logger should receive log events") +} + +// --- Test: WithCloseTimeout option --- + +func TestWithCloseTimeout(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok", WithCloseTimeout(10*time.Second)) + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + // We can't directly inspect the config, but creation should succeed. + assert.NotNil(t, tp) +} + +// --- Test: Data flows through the tunnel --- + +func TestTunnelProxy_DataRoundTrip(t *testing.T) { + _, wsURL := startEchoServer(t) + + tp, err := NewTunnelProxy(wsURL, "tok") + require.NoError(t, err) + defer func() { _ = tp.Close() }() + + conn, err := net.DialTimeout("tcp", tp.Addr(), 2*time.Second) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Send data and verify round-trip through the WebSocket echo server. + msg := []byte("round-trip-payload-12345") + _, err = conn.Write(msg) + require.NoError(t, err) + + buf := make([]byte, len(msg)) + _ = conn.SetReadDeadline(time.Now().Add(3 * time.Second)) + _, err = io.ReadFull(conn, buf) + require.NoError(t, err) + + assert.Equal(t, msg, buf) +} diff --git a/sdk/go/openshell/v1/errors.go b/sdk/go/openshell/v1/errors.go index 0033ae9775..fd6a5b2f2b 100644 --- a/sdk/go/openshell/v1/errors.go +++ b/sdk/go/openshell/v1/errors.go @@ -56,5 +56,5 @@ func IsUnimplemented(err error) bool { return types.IsUnimplemented(err) } // optimistic concurrency or an invalid state transition. func IsConflict(err error) bool { return types.IsConflict(err) } -// IsUnauthenticated returns true if the error indicates missing or invalid credentials. +// IsUnauthenticated returns true if the error indicates invalid or missing credentials. func IsUnauthenticated(err error) bool { return types.IsUnauthenticated(err) } diff --git a/sdk/go/openshell/v1/errors_test.go b/sdk/go/openshell/v1/errors_test.go index acc15c84b1..e1ec0d61f5 100644 --- a/sdk/go/openshell/v1/errors_test.go +++ b/sdk/go/openshell/v1/errors_test.go @@ -23,7 +23,7 @@ func TestStatusError_Error(t *testing.T) { } func TestStatusError_ErrorWithCause(t *testing.T) { - cause := fmt.Errorf("underlying issue") + cause := errors.New("underlying error") err := &StatusError{ Code: ErrorInvalidArgument, Message: "bad name", @@ -32,7 +32,7 @@ func TestStatusError_ErrorWithCause(t *testing.T) { s := err.Error() assert.Contains(t, s, "InvalidArgument") assert.Contains(t, s, "bad name") - assert.ErrorIs(t, err, cause) + assert.Equal(t, cause, errors.Unwrap(err)) } func TestIsNotFound(t *testing.T) { diff --git a/sdk/go/openshell/v1/example_fake_test.go b/sdk/go/openshell/v1/example_fake_test.go new file mode 100644 index 0000000000..c59256413e --- /dev/null +++ b/sdk/go/openshell/v1/example_fake_test.go @@ -0,0 +1,197 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1_test + +import ( + "context" + "fmt" + "log" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// ExampleNewClient_addSandbox demonstrates pre-seeding a fake client with +// a sandbox fixture. +func ExampleNewClient_addSandbox() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + // Pre-seed a sandbox that already exists in Ready state + client.AddSandbox("default", &types.Sandbox{ + Name: "pre-existing", + Status: types.SandboxStatus{ + Phase: types.SandboxReady, + }, + ResourceVersion: 5, + }) + + ctx := context.Background() + + sb, err := client.Sandboxes().Get(ctx, "default", "pre-existing") + if err != nil { + log.Fatal(err) + } + fmt.Println("Name:", sb.Name) + fmt.Println("Phase:", sb.Status.Phase) + // Output: + // Name: pre-existing + // Phase: Ready +} + +// ExampleNewClient_addProvider demonstrates pre-seeding a fake client with +// a provider fixture. +func ExampleNewClient_addProvider() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + // Pre-seed a provider + client.AddProvider("default", &types.Provider{ + Name: "seeded-provider", + Type: "openai", + }) + + ctx := context.Background() + + providers, err := client.Providers().List(ctx, "default") + if err != nil { + log.Fatal(err) + } + fmt.Println("Count:", len(providers)) + fmt.Println("Name:", providers[0].Name) + // Output: + // Count: 1 + // Name: seeded-provider +} + +// ExampleNewClient_withHealthResult demonstrates configuring the fake +// health sub-client to return a custom result. +func ExampleNewClient_withHealthResult() { + client := fake.NewClient(fake.WithHealthResult(&types.HealthResult{ + Healthy: false, + Version: "1.2.3", + })) + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + result, err := client.Health().Check(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Healthy:", result.Healthy) + fmt.Println("Version:", result.Version) + // Output: + // Healthy: false + // Version: 1.2.3 +} + +// ExampleNewClient_watchEvents demonstrates watching for sandbox events +// using the fake client. +func ExampleNewClient_watchEvents() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Start watching before creating + watcher, err := client.Sandboxes().Watch(ctx, "default", "my-sandbox") + if err != nil { + log.Fatal(err) + } + defer watcher.Stop() + + // Create triggers an ADDED event + _, err = client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + + event := <-watcher.ResultChan() + fmt.Println("Type:", event.Type) + fmt.Println("Name:", event.Object.Name) + // Output: + // Type: ADDED + // Name: my-sandbox +} + +// ExampleNewClient_stopOnTerminal demonstrates the StopOnTerminal watch +// option that automatically closes the watcher when a sandbox reaches a +// terminal phase. +func ExampleNewClient_stopOnTerminal() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Watch with StopOnTerminal + watcher, err := client.Sandboxes().Watch(ctx, "default", "my-sandbox", v1.WatchOptions{ + StopOnTerminal: true, + }) + if err != nil { + log.Fatal(err) + } + + // Create and transition to Ready + _, err = client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + _, err = client.Sandboxes().WaitReady(ctx, "default", "my-sandbox") + if err != nil { + log.Fatal(err) + } + + // Drain events, channel closes after terminal phase + var count int + for range watcher.ResultChan() { + count++ + } + fmt.Println("Events received:", count) + // Output: + // Events received: 2 +} + +// ExampleNewClient_inferenceRoute demonstrates setting and retrieving an +// inference route using the fake client. +func ExampleNewClient_inferenceRoute() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Set an inference route for a workspace + route, err := client.Inference().SetRoute(ctx, "my-workspace", &v1.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "", + TimeoutSecs: 120, + }) + if err != nil { + log.Fatal(err) + } + fmt.Printf("Set route v%d: %s/%s\n", route.Version, route.ProviderName, route.ModelID) + + // Retrieve the route + route, err = client.Inference().GetRoute(ctx, "my-workspace", "") + if err != nil { + log.Fatal(err) + } + fmt.Printf("Got route: %s/%s (timeout: %ds)\n", route.ProviderName, route.ModelID, route.TimeoutSecs) + + // Delete the route + err = client.Inference().DeleteRoute(ctx, "my-workspace", "") + if err != nil { + log.Fatal(err) + } + + // Verify deletion + _, err = client.Inference().GetRoute(ctx, "my-workspace", "") + fmt.Println("After delete:", v1.IsNotFound(err)) + // Output: + // Set route v1: openai/gpt-4 + // Got route: openai/gpt-4 (timeout: 120s) + // After delete: true +} diff --git a/sdk/go/openshell/v1/example_test.go b/sdk/go/openshell/v1/example_test.go new file mode 100644 index 0000000000..eb96fe8c11 --- /dev/null +++ b/sdk/go/openshell/v1/example_test.go @@ -0,0 +1,184 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1_test + +import ( + "context" + "fmt" + "log" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/fake" +) + +// ExampleClient_Sandboxes demonstrates the sandbox lifecycle: create a sandbox, +// wait for it to become ready, and then clean up. +func ExampleClient_Sandboxes() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Create a sandbox + sb, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + fmt.Println("Phase after create:", sb.Status.Phase) + + // Wait for the sandbox to become ready + sb, err = client.Sandboxes().WaitReady(ctx, "default", "my-sandbox") + if err != nil { + log.Fatal(err) + } + fmt.Println("Phase after wait:", sb.Status.Phase) + + // Clean up + if err := client.Sandboxes().Delete(ctx, "default", "my-sandbox"); err != nil { + log.Fatal(err) + } + fmt.Println("Deleted") + // Output: + // Phase after create: Provisioning + // Phase after wait: Ready + // Deleted +} + +// ExampleClient_Providers demonstrates registering and listing providers. +func ExampleClient_Providers() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Register a provider + _, err := client.Providers().Create(ctx, "default", &v1.Provider{ + Name: "my-openai", + Type: "openai", + }) + if err != nil { + log.Fatal(err) + } + + // List all providers + providers, err := client.Providers().List(ctx, "default") + if err != nil { + log.Fatal(err) + } + fmt.Println("Count:", len(providers)) + fmt.Println("Name:", providers[0].Name) + // Output: + // Count: 1 + // Name: my-openai +} + +// ExampleClient_Health demonstrates checking gateway health. +func ExampleClient_Health() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + result, err := client.Health().Check(ctx) + if err != nil { + log.Fatal(err) + } + fmt.Println("Healthy:", result.Healthy) + // Output: + // Healthy: true +} + +// ExampleClient_Exec demonstrates running a command in a sandbox. +// The fake client returns Unimplemented for exec operations, so this +// example shows the call pattern and error handling. +func ExampleClient_Exec() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := client.Exec().Run(ctx, "default", "my-sandbox", []string{"echo", "hello"}) + if v1.IsUnimplemented(err) { + fmt.Println("Exec requires a real gateway") + } + // Output: + // Exec requires a real gateway +} + +// ExampleClient_TCP demonstrates binding a local port to a sandbox port. +// The returned handle accepts and tunnels connections internally. +// +// The fake client returns Unimplemented for Listen, so this example shows +// the call pattern and error handling rather than a live tunnel. +func ExampleClient_TCP() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Bind local port 0 (OS-assigned) to sandbox port 8080. + ln, err := client.TCP().Listen(ctx, "default", "my-sandbox", 8080, 0) + if v1.IsUnimplemented(err) { + fmt.Println("Listen requires a real gateway") + } + if ln != nil { + // In production, dial ln.Addr() with the protocol client that should + // connect to the sandbox service. No Accept loop is required. + defer ln.Close() //nolint:errcheck + } + // Output: + // Listen requires a real gateway +} + +// ExampleIsNotFound demonstrates handling a not-found error. +func ExampleIsNotFound() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := client.Sandboxes().Get(ctx, "default", "nonexistent") + if v1.IsNotFound(err) { + fmt.Println("Sandbox not found") + } + // Output: + // Sandbox not found +} + +// ExampleIsAlreadyExists demonstrates handling a duplicate-creation error. +func ExampleIsAlreadyExists() { + client := fake.NewClient() + defer client.Close() //nolint:errcheck + + ctx := context.Background() + + // Create a sandbox + _, err := client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) + if err != nil { + log.Fatal(err) + } + + // Try to create the same sandbox again + _, err = client.Sandboxes().Create(ctx, "default", "my-sandbox", &v1.SandboxSpec{}, nil) + if v1.IsAlreadyExists(err) { + fmt.Println("Sandbox already exists") + } + // Output: + // Sandbox already exists +} + +// ExampleIsUnavailable demonstrates detecting a closed client. +func ExampleIsUnavailable() { + client := fake.NewClient() + _ = client.Close() + + ctx := context.Background() + + _, err := client.Sandboxes().Get(ctx, "default", "any") + if v1.IsUnavailable(err) { + fmt.Println("Client is closed") + } + // Output: + // Client is closed +} diff --git a/sdk/go/openshell/v1/exec_client.go b/sdk/go/openshell/v1/exec_client.go new file mode 100644 index 0000000000..c74fe7a5d3 --- /dev/null +++ b/sdk/go/openshell/v1/exec_client.go @@ -0,0 +1,334 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type execClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface +} + +func newExecClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *execClient { + return &execClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes} +} + +func (e *execClient) Run(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (*ExecResult, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, workspace, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + req := converter.ExecRequestToProto(sb.ID, command, opt) + + stream, err := e.client.ExecSandbox(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + var events []*pb.ExecSandboxEvent + for { + ev, recvErr := stream.Recv() + if recvErr == io.EOF { + break + } + if recvErr != nil { + return nil, converter.FromGRPCError(recvErr) + } + events = append(events, ev) + } + + return converter.ExecResultFromEvents(events) +} + +func (e *execClient) Stream(ctx context.Context, workspace, sandboxName string, command []string, opts ...ExecOptions) (ExecStream, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, workspace, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + req := converter.ExecRequestToProto(sb.ID, command, opt) + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := e.client.ExecSandbox(streamCtx, req) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + return &execStream{stream: stream, cancel: cancel}, nil +} + +func (e *execClient) Interactive(ctx context.Context, workspace, sandboxName string, command []string, cols, rows uint32, opts ...ExecOptions) (InteractiveSession, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + sb, err := e.sandboxes.Get(ctx, workspace, sandboxName) + if err != nil { + return nil, err + } + + var opt *ExecOptions + if len(opts) > 0 { + opt = &opts[0] + } + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := e.client.ExecSandboxInteractive(streamCtx) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + startReq := converter.ExecInteractiveRequestToProto(sb.ID, command, cols, rows, opt) + if sendErr := stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Start{Start: startReq}, + }); sendErr != nil { + cancel() + return nil, converter.FromGRPCError(sendErr) + } + + return newInteractiveSession(streamCtx, cancel, stream), nil +} + +// execStream wraps a server-streaming RPC into the ExecStream interface. +type execStream struct { + stream grpc.ServerStreamingClient[pb.ExecSandboxEvent] + cancel context.CancelFunc + exitCode int + exited bool + hasExit bool +} + +func (s *execStream) Next() (*ExecChunk, error) { + if s.exited { + return nil, io.EOF + } + + ev, err := s.stream.Recv() + if err == io.EOF { + return nil, io.EOF + } + if err != nil { + return nil, converter.FromGRPCError(err) + } + + chunk, code, convErr := converter.ExecChunkFromEvent(ev) + if convErr != nil { + return nil, convErr + } + if chunk != nil { + return chunk, nil + } + // nil chunk with no error means exit event + s.exitCode = code + s.exited = true + s.hasExit = true + return nil, io.EOF +} + +func (s *execStream) ExitCode() (int, error) { + if !s.exited { + for { + _, err := s.Next() + if err == io.EOF { + break + } + if err != nil { + return -1, err + } + } + } + if !s.hasExit { + return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"} + } + return s.exitCode, nil +} + +func (s *execStream) Close() error { + if s.cancel != nil { + s.cancel() + } + return nil +} + +// interactiveSession wraps a bidirectional streaming RPC into the InteractiveSession interface. +// A background goroutine owns the Recv loop and routes events to dataCh (for Read) +// and exitCh (for ExitCode), preventing concurrent Recv calls on the stream. +type interactiveSession struct { + stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent] + cancel context.CancelFunc + sendMu sync.Mutex + dataCh chan []byte + exitCh chan int + done chan struct{} + errOnce sync.Once + err error + buf []byte + + exitMu sync.Mutex + exitCode int + hasExitCode bool +} + +func newInteractiveSession(ctx context.Context, cancel context.CancelFunc, stream grpc.BidiStreamingClient[pb.ExecSandboxInput, pb.ExecSandboxEvent]) *interactiveSession { + s := &interactiveSession{ + stream: stream, + cancel: cancel, + dataCh: make(chan []byte, 64), + exitCh: make(chan int, 1), + done: make(chan struct{}), + } + go s.readLoop(ctx) + return s +} + +func (s *interactiveSession) setErr(err error) { + s.errOnce.Do(func() { s.err = err }) +} + +func (s *interactiveSession) readLoop(ctx context.Context) { + defer close(s.dataCh) + defer close(s.done) + for { + ev, err := s.stream.Recv() + if err != nil { + if err != io.EOF { + s.setErr(converter.FromGRPCError(err)) + } + return + } + + chunk, code, convErr := converter.ExecChunkFromEvent(ev) + if convErr != nil { + s.setErr(convErr) + return + } + // nil chunk with no error means exit event + if chunk == nil { + select { + case s.exitCh <- code: + default: + } + return + } + select { + case s.dataCh <- chunk.Data: + case <-ctx.Done(): + return + } + } +} + +func (s *interactiveSession) Read(p []byte) (int, error) { + if len(s.buf) > 0 { + n := copy(p, s.buf) + s.buf = s.buf[n:] + return n, nil + } + + data, ok := <-s.dataCh + if !ok { + if s.err != nil { + return 0, s.err + } + return 0, io.EOF + } + n := copy(p, data) + if n < len(data) { + s.buf = append(s.buf, data[n:]...) + } + return n, nil +} + +func (s *interactiveSession) Write(p []byte) (int, error) { + s.sendMu.Lock() + defer s.sendMu.Unlock() + err := s.stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Stdin{Stdin: p}, + }) + if err != nil { + return 0, converter.FromGRPCError(err) + } + return len(p), nil +} + +func (s *interactiveSession) Resize(cols, rows uint32) error { + s.sendMu.Lock() + defer s.sendMu.Unlock() + err := s.stream.Send(&pb.ExecSandboxInput{ + Payload: &pb.ExecSandboxInput_Resize{ + Resize: &pb.ExecSandboxWindowResize{ + Cols: cols, + Rows: rows, + }, + }, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (s *interactiveSession) ExitCode() (int, error) { + s.exitMu.Lock() + if s.hasExitCode { + code := s.exitCode + s.exitMu.Unlock() + return code, nil + } + s.exitMu.Unlock() + + select { + case code := <-s.exitCh: + s.exitMu.Lock() + s.exitCode = code + s.hasExitCode = true + s.exitMu.Unlock() + return code, nil + case <-s.done: + select { + case code := <-s.exitCh: + s.exitMu.Lock() + s.exitCode = code + s.hasExitCode = true + s.exitMu.Unlock() + return code, nil + default: + if s.err != nil { + return -1, s.err + } + return -1, &StatusError{Code: ErrorInternal, Message: "stream ended without exit event"} + } + } +} + +func (s *interactiveSession) Close() error { + s.cancel() + err := s.stream.CloseSend() + <-s.done + return err +} diff --git a/sdk/go/openshell/v1/exec_client_test.go b/sdk/go/openshell/v1/exec_client_test.go new file mode 100644 index 0000000000..85c1928c9c --- /dev/null +++ b/sdk/go/openshell/v1/exec_client_test.go @@ -0,0 +1,654 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "io" + "net" + "sync" + "testing" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +// stubSandboxResolver implements SandboxInterface for testing name-to-ID resolution. +// Get returns a Sandbox with ID = "sb-" + name. All other methods panic. +type stubSandboxResolver struct { + getErr error // if non-nil, Get returns this error +} + +func (r *stubSandboxResolver) Get(_ context.Context, _, name string) (*Sandbox, error) { + if r.getErr != nil { + return nil, r.getErr + } + return &Sandbox{ID: "sb-" + name, Name: name}, nil +} + +func (r *stubSandboxResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string, ...CreateOptions) (*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) Delete(context.Context, string, string) error { + panic("not implemented") +} +func (r *stubSandboxResolver) AttachProvider(context.Context, string, string, string, uint64) (*AttachProviderResult, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) DetachProvider(context.Context, string, string, string, uint64) (*DetachProviderResult, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) ListProviders(context.Context, string, string) ([]*Provider, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) WaitReady(context.Context, string, string, ...WaitOptions) (*Sandbox, error) { + panic("not implemented") +} +func (r *stubSandboxResolver) Watch(context.Context, string, string, ...WatchOptions) (WatchInterface[*Sandbox], error) { + panic("not implemented") +} +func (r *stubSandboxResolver) GetLogs(context.Context, string, string, ...LogOption) (*LogResult, error) { + panic("not implemented") +} + +type mockExecServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + execEvents []*pb.ExecSandboxEvent + execErr error + lastExecRequest *pb.ExecSandboxRequest + + interactiveEvents []*pb.ExecSandboxEvent + interactiveErr error + interactiveWaitInput bool + interactiveBlock bool + receivedInputs []*pb.ExecSandboxInput +} + +func newMockExecServer() *mockExecServer { + return &mockExecServer{} +} + +func (s *mockExecServer) ExecSandbox(req *pb.ExecSandboxRequest, stream grpc.ServerStreamingServer[pb.ExecSandboxEvent]) error { + s.mu.Lock() + s.lastExecRequest = req + events := make([]*pb.ExecSandboxEvent, len(s.execEvents)) + copy(events, s.execEvents) + execErr := s.execErr + s.mu.Unlock() + + if execErr != nil { + return execErr + } + + for _, ev := range events { + if err := stream.Send(ev); err != nil { + return err + } + } + return nil +} + +func (s *mockExecServer) ExecSandboxInteractive(stream grpc.BidiStreamingServer[pb.ExecSandboxInput, pb.ExecSandboxEvent]) error { + s.mu.Lock() + interactiveErr := s.interactiveErr + interactiveBlock := s.interactiveBlock + events := make([]*pb.ExecSandboxEvent, len(s.interactiveEvents)) + copy(events, s.interactiveEvents) + s.mu.Unlock() + + if interactiveErr != nil { + return interactiveErr + } + + // Read the start message + startMsg, err := stream.Recv() + if err != nil { + return err + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, startMsg) + s.mu.Unlock() + if interactiveBlock { + <-stream.Context().Done() + return stream.Context().Err() + } + + s.mu.Lock() + waitInput := s.interactiveWaitInput + s.mu.Unlock() + + if waitInput { + msg, recvErr := stream.Recv() + if recvErr != nil { + return recvErr + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, msg) + s.mu.Unlock() + } + + // Read subsequent messages until client closes, collecting them + go func() { + for { + msg, recvErr := stream.Recv() + if recvErr != nil { + return + } + s.mu.Lock() + s.receivedInputs = append(s.receivedInputs, msg) + s.mu.Unlock() + } + }() + + // Send canned events + for _, ev := range events { + if err := stream.Send(ev); err != nil { + return err + } + } + return nil +} + +func setupExecTest(t *testing.T, mock *mockExecServer) (*execClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newExecClient(conn, &stubSandboxResolver{}), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- T043: Run and Stream tests --- + +func TestExecRun(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("hello ")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("world\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("warn\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + result, err := client.Run(context.Background(), "default", "test-sandbox", []string{"echo", "hello", "world"}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.ExitCode) + assert.Equal(t, []byte("hello world\n"), result.Stdout) + assert.Equal(t, []byte("warn\n"), result.Stderr) +} + +func TestExecRun_WithOptions(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + opts := ExecOptions{ + Env: map[string]string{"FOO": "bar"}, + WorkDir: "/tmp", + } + result, err := client.Run(context.Background(), "default", "test-sandbox", []string{"ls"}, opts) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 0, result.ExitCode) + + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Equal(t, "sb-test-sandbox", mock.lastExecRequest.GetSandboxId()) + assert.Equal(t, []string{"ls"}, mock.lastExecRequest.GetCommand()) + assert.Equal(t, "/tmp", mock.lastExecRequest.GetWorkdir()) + assert.Equal(t, map[string]string{"FOO": "bar"}, mock.lastExecRequest.GetEnvironment()) +} + +func TestExecRun_NonZeroExit(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("fail\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + result, err := client.Run(context.Background(), "default", "test-sandbox", []string{"false"}) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, 1, result.ExitCode) + assert.Empty(t, result.Stdout) + assert.Equal(t, []byte("fail\n"), result.Stderr) +} + +func TestExecRun_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.execErr = status.Error(codes.NotFound, "sandbox not found") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + _, err := client.Run(context.Background(), "default", "missing-sandbox", []string{"ls"}) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecStream(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("err1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 42}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "default", "test-sandbox", []string{"cat"}) + require.NoError(t, err) + require.NotNil(t, stream) + defer func() { _ = stream.Close() }() + + chunk1, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStdout, chunk1.Stream) + assert.Equal(t, []byte("line1\n"), chunk1.Data) + + chunk2, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStderr, chunk2.Stream) + assert.Equal(t, []byte("err1\n"), chunk2.Data) + + chunk3, err := stream.Next() + require.NoError(t, err) + assert.Equal(t, StreamStdout, chunk3.Stream) + assert.Equal(t, []byte("line2\n"), chunk3.Data) + + // Next call after exit should return io.EOF + _, err = stream.Next() + assert.ErrorIs(t, err, io.EOF) + + exitCode, err := stream.ExitCode() + require.NoError(t, err) + assert.Equal(t, 42, exitCode) +} + +func TestExecStream_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.execErr = status.Error(codes.Internal, "internal error") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "default", "test-sandbox", []string{"ls"}) + if err != nil { + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) + return + } + _, err = stream.Next() + require.Error(t, err) +} + +func TestExecStream_EmptyOutput(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "default", "test-sandbox", []string{"true"}) + require.NoError(t, err) + defer func() { _ = stream.Close() }() + + _, err = stream.Next() + assert.ErrorIs(t, err, io.EOF) + + exitCode, err := stream.ExitCode() + require.NoError(t, err) + assert.Equal(t, 0, exitCode) +} + +// --- T044: Interactive session tests --- + +func TestExecInteractive(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("output\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/bash"}, 80, 24) + require.NoError(t, err) + require.NotNil(t, session) + defer func() { _ = session.Close() }() + + // Read output + buf := make([]byte, 1024) + n, err := session.Read(buf) + require.NoError(t, err) + assert.Equal(t, "$ ", string(buf[:n])) + + // Verify start message was received + mock.mu.Lock() + require.GreaterOrEqual(t, len(mock.receivedInputs), 1) + startInput := mock.receivedInputs[0] + mock.mu.Unlock() + + startReq := startInput.GetStart() + require.NotNil(t, startReq) + assert.Equal(t, "sb-test-sandbox", startReq.GetSandboxId()) + assert.Equal(t, []string{"/bin/bash"}, startReq.GetCommand()) + assert.True(t, startReq.GetTty()) + assert.Equal(t, uint32(80), startReq.GetCols()) + assert.Equal(t, uint32(24), startReq.GetRows()) +} + +func TestExecInteractive_CloseCancelsReceiveStream(t *testing.T) { + mock := newMockExecServer() + mock.interactiveBlock = true + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + + closed := make(chan error, 1) + go func() { closed <- session.Close() }() + + select { + case err := <-closed: + require.NoError(t, err) + case <-time.After(time.Second): + t.Fatal("InteractiveSession.Close did not cancel the receive stream") + } +} + +func TestExecInteractive_Write(t *testing.T) { + mock := newMockExecServer() + mock.interactiveWaitInput = true + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + n, err := session.Write([]byte("ls\n")) + require.NoError(t, err) + assert.Equal(t, 3, n) +} + +func TestExecInteractive_Resize(t *testing.T) { + mock := newMockExecServer() + mock.interactiveWaitInput = true + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("$ ")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + defer func() { _ = session.Close() }() + + err = session.Resize(120, 40) + require.NoError(t, err) +} + +func TestExecInteractive_ServerError(t *testing.T) { + mock := newMockExecServer() + mock.interactiveErr = status.Error(codes.PermissionDenied, "not allowed") + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24) + if err != nil { + assert.True(t, IsPermissionDenied(err), "expected permission denied, got: %v", err) + return + } + buf := make([]byte, 1024) + _, err = session.Read(buf) + require.Error(t, err) +} + +func TestExecInteractive_ExitCode(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("done\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 130}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + + // Drain output + buf := make([]byte, 1024) + for { + _, readErr := session.Read(buf) + if readErr != nil { + break + } + } + + exitCode, err := session.ExitCode() + require.NoError(t, err) + assert.Equal(t, 130, exitCode) + + _ = session.Close() +} + +func TestExecInteractive_ConcurrentReadAndExitCode(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line3\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "default", "test-sandbox", []string{"sh"}, 80, 24) + require.NoError(t, err) + + var wg sync.WaitGroup + var readData []byte + var readErr error + + wg.Add(1) + go func() { + defer wg.Done() + buf := make([]byte, 1024) + for { + n, err := session.Read(buf) + if err != nil { + readErr = err + return + } + readData = append(readData, buf[:n]...) + } + }() + + exitCode, exitErr := session.ExitCode() + wg.Wait() + + require.NoError(t, exitErr) + assert.Equal(t, 0, exitCode) + assert.Equal(t, io.EOF, readErr) + assert.Contains(t, string(readData), "line1\n") +} + +// --- Name-to-ID resolution tests --- + +func TestExecRun_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + _, err := client.Run(context.Background(), "default", "my-sandbox", []string{"echo", "hi"}) + require.NoError(t, err) + + mock.mu.Lock() + defer mock.mu.Unlock() + // Verify the proto request contains the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId()) +} + +func TestExecRun_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Run(context.Background(), "default", "nonexistent", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecStream_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.execEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + stream, err := client.Stream(context.Background(), "default", "my-sandbox", []string{"echo"}) + require.NoError(t, err) + + _, _ = stream.ExitCode() + _ = stream.Close() + + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Equal(t, "sb-my-sandbox", mock.lastExecRequest.GetSandboxId()) +} + +func TestExecStream_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Stream(context.Background(), "default", "nonexistent", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecInteractive_ResolvesNameToID(t *testing.T) { + mock := newMockExecServer() + mock.interactiveEvents = []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + client, cleanup := setupExecTest(t, mock) + defer cleanup() + + session, err := client.Interactive(context.Background(), "default", "my-sandbox", []string{"/bin/sh"}, 80, 24) + require.NoError(t, err) + + _, _ = session.ExitCode() + _ = session.Close() + + mock.mu.Lock() + defer mock.mu.Unlock() + require.NotEmpty(t, mock.receivedInputs) + startReq := mock.receivedInputs[0].GetStart() + require.NotNil(t, startReq) + assert.Equal(t, "sb-my-sandbox", startReq.GetSandboxId()) +} + +func TestExecInteractive_ResolutionError(t *testing.T) { + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newExecClient(stubConn(t), resolver) + + _, err := client.Interactive(context.Background(), "default", "nonexistent", []string{"/bin/sh"}, 80, 24) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestExecRun_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Run(context.Background(), "default", "", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestExecStream_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Stream(context.Background(), "default", "", []string{"ls"}) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestExecInteractive_EmptySandboxName(t *testing.T) { + client := newExecClient(stubConn(t), &stubSandboxResolver{}) + _, err := client.Interactive(context.Background(), "default", "", []string{"/bin/sh"}, 80, 24) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// stubConn creates a minimal gRPC connection for resolution-error tests +// where the RPC is never reached. +func stubConn(t *testing.T) *grpc.ClientConn { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, newMockExecServer()) + go func() { _ = srv.Serve(lis) }() + t.Cleanup(func() { srv.Stop() }) + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + t.Cleanup(func() { _ = conn.Close() }) + return conn +} diff --git a/sdk/go/openshell/v1/fake/broadcaster.go b/sdk/go/openshell/v1/fake/broadcaster.go new file mode 100644 index 0000000000..2f0e38dbc5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/broadcaster.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +const watchChannelBuffer = 100 + +// watchBroadcaster manages a set of watchers and broadcasts events to them. +// Each watcher can optionally filter events by resource name. +type watchBroadcaster[T any] struct { + mu sync.Mutex + watchers []*fakeWatcher[T] +} + +// newWatchBroadcaster creates a new watchBroadcaster. +func newWatchBroadcaster[T any]() *watchBroadcaster[T] { + return &watchBroadcaster[T]{} +} + +// Watch registers a new watcher. If name is non-empty, the watcher only +// receives events matching that name. If name is empty, all events are +// delivered. The returned WatchInterface must be stopped by the caller. +func (b *watchBroadcaster[T]) Watch(name string) types.WatchInterface[T] { + w := &fakeWatcher[T]{ + ch: make(chan types.Event[T], watchChannelBuffer), + name: name, + wake: make(chan struct{}, 1), + stopCh: make(chan struct{}), + done: make(chan struct{}), + } + go w.run() + + b.mu.Lock() + b.watchers = append(b.watchers, w) + b.mu.Unlock() + + return w +} + +// Broadcast sends an event to all registered watchers whose name filter +// matches (or whose filter is empty). Stopped watchers are skipped and +// cleaned up lazily. +func (b *watchBroadcaster[T]) Broadcast(event types.Event[T], name string) { + b.mu.Lock() + defer b.mu.Unlock() + + active := b.watchers[:0] + for _, w := range b.watchers { + if w.isStopped() { + continue + } + active = append(active, w) + + if w.name != "" && w.name != name { + continue + } + + w.send(event) + } + b.watchers = active +} + +// StopAll closes all active watchers. +func (b *watchBroadcaster[T]) StopAll() { + b.mu.Lock() + defer b.mu.Unlock() + + for _, w := range b.watchers { + w.Stop() + } + b.watchers = nil +} + +// fakeWatcher implements types.WatchInterface[T] with a buffered channel +// and optional name filter. +type fakeWatcher[T any] struct { + ch chan types.Event[T] + name string + once sync.Once + stopped bool + mu sync.Mutex + queue []types.Event[T] + wake chan struct{} + stopCh chan struct{} + done chan struct{} +} + +// ResultChan returns the channel delivering watch events. +func (w *fakeWatcher[T]) ResultChan() <-chan types.Event[T] { + return w.ch +} + +// send delivers an event to the watcher under its lock, preventing a +// race between Broadcast (send) and Stop (close) on w.ch. +func (w *fakeWatcher[T]) send(event types.Event[T]) { + w.mu.Lock() + defer w.mu.Unlock() + if w.stopped { + return + } + w.queue = append(w.queue, event) + select { + case w.wake <- struct{}{}: + default: + } +} + +// Stop closes the event channel. It is safe to call multiple times. +func (w *fakeWatcher[T]) Stop() { + w.once.Do(func() { + w.mu.Lock() + w.stopped = true + w.mu.Unlock() + close(w.stopCh) + <-w.done + }) +} + +// isStopped returns true if Stop has been called. +func (w *fakeWatcher[T]) isStopped() bool { + w.mu.Lock() + defer w.mu.Unlock() + return w.stopped +} + +func (w *fakeWatcher[T]) run() { + defer close(w.done) + defer close(w.ch) + for { + w.mu.Lock() + if len(w.queue) > 0 { + event := w.queue[0] + w.queue = w.queue[1:] + w.mu.Unlock() + select { + case w.ch <- event: + case <-w.stopCh: + return + } + continue + } + w.mu.Unlock() + select { + case <-w.wake: + case <-w.stopCh: + return + } + } +} diff --git a/sdk/go/openshell/v1/fake/broadcaster_test.go b/sdk/go/openshell/v1/fake/broadcaster_test.go new file mode 100644 index 0000000000..8f170df885 --- /dev/null +++ b/sdk/go/openshell/v1/fake/broadcaster_test.go @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T005: watchBroadcaster tests --- + +func TestWatchBroadcaster_Watch_ReceivesEvents(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + defer w.Stop() + + item := &testItem{Name: "alpha", Value: "v1"} + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: item}, "alpha") + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } +} + +func TestWatchBroadcaster_DoesNotDropBurstEvents(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + w := b.Watch("") + defer w.Stop() + + const count = watchChannelBuffer + 50 + for i := range count { + b.Broadcast(types.Event[*testItem]{Object: &testItem{Name: fmt.Sprint(i)}}, "") + } + for range count { + select { + case <-w.ResultChan(): + case <-time.After(time.Second): + t.Fatal("watch event was dropped") + } + } +} + +func TestWatchBroadcaster_Watch_NameFiltering(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + // Watcher filtered to "alpha" only + wAlpha := b.Watch("alpha") + defer wAlpha.Stop() + + // Watcher filtered to "beta" only + wBeta := b.Watch("beta") + defer wBeta.Stop() + + // Broadcast event for "alpha" + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // alpha watcher should receive event + select { + case ev := <-wAlpha.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("alpha watcher: timed out waiting for event") + } + + // beta watcher should NOT receive event + select { + case ev := <-wBeta.ResultChan(): + t.Fatalf("beta watcher: unexpected event %v", ev) + case <-time.After(50 * time.Millisecond): + // Expected: no event for beta + } +} + +func TestWatchBroadcaster_Watch_EmptyNameReceivesAll(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + // Watcher with empty name receives all events + w := b.Watch("") + defer w.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "beta"}}, "beta") + + received := make([]string, 0, 2) + for i := 0; i < 2; i++ { + select { + case ev := <-w.ResultChan(): + received = append(received, ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } + assert.ElementsMatch(t, []string{"alpha", "beta"}, received) +} + +func TestWatchBroadcaster_MultipleWatchers(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + defer w1.Stop() + w2 := b.Watch("") + defer w2.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // Both watchers should receive the event + for _, w := range []types.WatchInterface[*testItem]{w1, w2} { + select { + case ev := <-w.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event") + } + } +} + +func TestWatchBroadcaster_Stop_ClosesChannel(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + w.Stop() + + // Channel should be closed after Stop + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should be closed after Stop") +} + +func TestWatchBroadcaster_Stop_Idempotent(_ *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + + // Multiple stops should not panic + w.Stop() + w.Stop() +} + +func TestWatchBroadcaster_StopAll(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + w2 := b.Watch("alpha") + + b.StopAll() + + // Both channels should be closed + _, ok1 := <-w1.ResultChan() + assert.False(t, ok1, "w1 channel should be closed after StopAll") + + _, ok2 := <-w2.ResultChan() + assert.False(t, ok2, "w2 channel should be closed after StopAll") +} + +func TestWatchBroadcaster_BroadcastAfterStop_NoDelivery(_ *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w := b.Watch("") + w.Stop() + + // Broadcasting after a watcher stops should not panic + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") +} + +func TestWatchBroadcaster_StoppedWatcher_RemovedFromBroadcast(t *testing.T) { + b := newWatchBroadcaster[*testItem]() + + w1 := b.Watch("") + w2 := b.Watch("") + + // Stop w1, keep w2 + w1.Stop() + + b.Broadcast(types.Event[*testItem]{Type: types.EventAdded, Object: &testItem{Name: "alpha"}}, "alpha") + + // w2 should still receive events + select { + case ev := <-w2.ResultChan(): + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event on w2") + } + + w2.Stop() +} diff --git a/sdk/go/openshell/v1/fake/config.go b/sdk/go/openshell/v1/fake/config.go new file mode 100644 index 0000000000..014d421261 --- /dev/null +++ b/sdk/go/openshell/v1/fake/config.go @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeConfigClient implements v1.ConfigInterface. All methods return +// Unimplemented because configuration management requires a real gateway. +type fakeConfigClient struct { + closedFunc func() bool +} + +// newFakeConfigClient creates a new fakeConfigClient. +func newFakeConfigClient(closedFunc func() bool) *fakeConfigClient { + return &fakeConfigClient{closedFunc: closedFunc} +} + +// GetSandbox returns Unimplemented. +func (c *fakeConfigClient) GetSandbox(_ context.Context, _, sandboxName string) (*types.SandboxConfig, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetSandbox is not supported by the fake client"} +} + +// GetGateway returns Unimplemented. +func (c *fakeConfigClient) GetGateway(_ context.Context) (*types.GatewayConfig, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetGateway is not supported by the fake client"} +} + +// Update returns Unimplemented. A nil update is rejected with InvalidArgument +// to match the real client's behavior. +func (c *fakeConfigClient) Update(_ context.Context, _ string, update *types.ConfigUpdate) (*types.ConfigUpdateResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if update == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "update must not be nil"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Update is not supported by the fake client"} +} + +// Compile-time check that fakeConfigClient implements v1.ConfigInterface. +var _ v1.ConfigInterface = (*fakeConfigClient)(nil) diff --git a/sdk/go/openshell/v1/fake/config_test.go b/sdk/go/openshell/v1/fake/config_test.go new file mode 100644 index 0000000000..54c73af2d3 --- /dev/null +++ b/sdk/go/openshell/v1/fake/config_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T020: fakeConfigClient stub tests --- + +func TestFakeConfig_GetSandbox_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.GetSandbox(context.Background(), "default", "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_GetGateway_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.GetGateway(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_Update_ReturnsUnimplemented(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.Update(context.Background(), "default", &types.ConfigUpdate{ + Name: "sandbox-1", + SettingKey: "key", + }) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeConfig_GetSandbox_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.GetSandbox(context.Background(), "default", "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeConfig_GetGateway_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.GetGateway(context.Background()) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeConfig_Update_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeConfigClient(func() bool { return true }) + _, err := c.Update(context.Background(), "default", &types.ConfigUpdate{ + Name: "sandbox-1", + SettingKey: "key", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- T033: MergeOperations acceptance test --- + +func TestFakeConfig_Update_MergeOperationsAccepted(t *testing.T) { + c := newFakeConfigClient(func() bool { return false }) + _, err := c.Update(context.Background(), "default", &types.ConfigUpdate{ + Name: "sandbox-1", + MergeOperations: []types.PolicyMergeOperation{{RemoveRule: &types.RemoveNetworkRule{RuleName: "test"}}}, + }) + require.Error(t, err) + // Should return Unimplemented (not InvalidArgument) — MergeOperations are now accepted + assert.True(t, types.IsUnimplemented(err)) + assert.False(t, types.IsInvalidArgument(err)) +} diff --git a/sdk/go/openshell/v1/fake/doc.go b/sdk/go/openshell/v1/fake/doc.go new file mode 100644 index 0000000000..2b17dfcd63 --- /dev/null +++ b/sdk/go/openshell/v1/fake/doc.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package fake provides an in-memory fake implementation of the OpenShell SDK +// client interfaces for use in consumer test suites. +// +// The fake client follows the client-go/kubernetes/fake pattern: it maintains +// in-memory stores for sandboxes and providers, supports watch event broadcasting, +// and returns the same StatusError codes as the real client for equivalent error +// conditions (NotFound, AlreadyExists, Unavailable, Unimplemented). +// +// All operations are safe for concurrent use from multiple goroutines. +// +// # Usage +// +// Create a FakeClient, exercise the sandbox lifecycle, and assert results: +// +// func TestSandboxLifecycle(t *testing.T) { +// client := fake.NewClient() +// defer client.Close() +// +// ctx := context.Background() +// +// // Create a sandbox — starts in Provisioning phase +// sb, err := client.Sandboxes().Create(ctx, "my-sandbox", &v1.SandboxSpec{}, nil) +// require.NoError(t, err) +// assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) +// +// // Wait until ready — transitions synchronously in the fake +// sb, err = client.Sandboxes().WaitReady(ctx, "my-sandbox") +// require.NoError(t, err) +// assert.Equal(t, types.SandboxReady, sb.Status.Phase) +// +// // Clean up +// require.NoError(t, client.Sandboxes().Delete(ctx, "my-sandbox")) +// } +package fake diff --git a/sdk/go/openshell/v1/fake/exec.go b/sdk/go/openshell/v1/fake/exec.go new file mode 100644 index 0000000000..242fbf14ea --- /dev/null +++ b/sdk/go/openshell/v1/fake/exec.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var _ v1.ExecInterface = (*fakeExecClient)(nil) + +// fakeExecClient implements v1.ExecInterface. All methods return +// Unimplemented because command execution requires a real sandbox runtime. +type fakeExecClient struct { + closedFunc func() bool +} + +// newFakeExecClient creates a new fakeExecClient. +func newFakeExecClient(closedFunc func() bool) *fakeExecClient { + return &fakeExecClient{closedFunc: closedFunc} +} + +// Run returns Unimplemented. +func (c *fakeExecClient) Run(_ context.Context, _, sandboxName string, _ []string, _ ...v1.ExecOptions) (*types.ExecResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Run is not supported by the fake client"} +} + +// Stream returns Unimplemented. +func (c *fakeExecClient) Stream(_ context.Context, _, sandboxName string, _ []string, _ ...v1.ExecOptions) (v1.ExecStream, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Stream is not supported by the fake client"} +} + +// Interactive returns Unimplemented. +func (c *fakeExecClient) Interactive(_ context.Context, _, sandboxName string, _ []string, _, _ uint32, _ ...v1.ExecOptions) (v1.InteractiveSession, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Interactive is not supported by the fake client"} +} diff --git a/sdk/go/openshell/v1/fake/exec_test.go b/sdk/go/openshell/v1/fake/exec_test.go new file mode 100644 index 0000000000..bc4163f19e --- /dev/null +++ b/sdk/go/openshell/v1/fake/exec_test.go @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T021: Exec stub tests --- + +func TestExec_Run_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Run(ctx, "default", "sandbox-1", []string{"echo", "hello"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Stream_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Stream(ctx, "default", "sandbox-1", []string{"tail", "-f", "/var/log/app.log"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Interactive_Unimplemented(t *testing.T) { + ec := newFakeExecClient(func() bool { return false }) + ctx := context.Background() + + _, err := ec.Interactive(ctx, "default", "sandbox-1", []string{"/bin/bash"}, 80, 24) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestExec_Run_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Run(ctx, "default", "sandbox-1", []string{"echo"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestExec_Stream_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Stream(ctx, "default", "sandbox-1", []string{"tail"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestExec_Interactive_ClosedClient(t *testing.T) { + ec := newFakeExecClient(func() bool { return true }) + ctx := context.Background() + + _, err := ec.Interactive(ctx, "default", "sandbox-1", []string{"/bin/bash"}, 80, 24) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/fake.go b/sdk/go/openshell/v1/fake/fake.go new file mode 100644 index 0000000000..d2978bc08c --- /dev/null +++ b/sdk/go/openshell/v1/fake/fake.go @@ -0,0 +1,212 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sync" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Client implements v1.ClientInterface with in-memory stores. It is +// designed for testing consumers of the OpenShell SDK without requiring a +// real gRPC connection. Create one with NewClient. +type Client struct { + sandboxStore *objectStore[*types.Sandbox] + providerStore *objectStore[*types.Provider] + workspaceStore *objectStore[*types.Workspace] + memberStore *objectStore[*types.WorkspaceMember] + sandboxBroadcaster *watchBroadcaster[*types.Sandbox] + + sandboxes v1.SandboxInterface + providers v1.ProviderInterface + services v1.ServiceInterface + exec v1.ExecInterface + files v1.FileInterface + health v1.HealthInterface + ssh v1.SSHInterface + tcp v1.TCPInterface + cfg v1.ConfigInterface + policy v1.PolicyInterface + workspaces v1.WorkspaceInterface + inference v1.InferenceInterface + + closeOnce sync.Once + closed bool + mu sync.RWMutex // guards closed flag +} + +// ClientOption configures a Client during construction. +type ClientOption func(*Client) + +// WithHealthResult returns an option that configures the health sub-client +// to return the given result instead of the default healthy response. +func WithHealthResult(r *types.HealthResult) ClientOption { + return func(fc *Client) { + fc.health.(*fakeHealthClient).result = r + } +} + +// WithGatewayInfo returns an option that configures the health sub-client +// to return the given gateway info instead of the default response. +func WithGatewayInfo(info *types.GatewayInfo) ClientOption { + return func(fc *Client) { + fc.health.(*fakeHealthClient).gatewayInfo = copyGatewayInfo(info) + } +} + +// WithCurrentUser returns an option that configures the health sub-client +// to return the given current user instead of the default response. +func WithCurrentUser(user *types.CurrentUser) ClientOption { + return func(fc *Client) { + fc.health.(*fakeHealthClient).currentUser = copyCurrentUser(user) + } +} + +// NewClient creates a new Client with all sub-clients wired up. +// Options (e.g., WithHealthResult) are applied after the default setup. +func NewClient(opts ...ClientOption) *Client { + fc := &Client{ + sandboxStore: newobjectStore(sandboxName, copySandbox), + providerStore: newobjectStore(providerName, copyProvider), + workspaceStore: newobjectStore(workspaceName, copyWorkspace), + memberStore: newobjectStore(memberName, copyMember), + sandboxBroadcaster: newWatchBroadcaster[*types.Sandbox](), + } + + fc.sandboxes = newFakeSandboxClient(fc.sandboxStore, fc.sandboxBroadcaster, fc.isClosed) + fc.providers = newFakeProviderClient(fc.providerStore, fc.isClosed) + fc.services = newFakeServiceClient(fc.isClosed) + fc.exec = newFakeExecClient(fc.isClosed) + fc.files = newFakeFileClient(fc.isClosed) + fc.health = newFakeHealthClient(nil, fc.isClosed) + fc.ssh = newFakeSSHClient(fc.isClosed) + fc.tcp = newFakeTCPClient(fc.isClosed) + fc.cfg = newFakeConfigClient(fc.isClosed) + fc.policy = newFakePolicyClient(fc.isClosed) + fc.workspaces = newFakeWorkspaceClient(fc.workspaceStore, fc.memberStore, fc.isClosed) + fc.inference = newFakeInferenceClient(fc.isClosed) + + for _, opt := range opts { + opt(fc) + } + + return fc +} + +// isClosed returns true if the client has been closed. This is passed to +// all sub-clients as the closedFunc parameter. +func (fc *Client) isClosed() bool { + fc.mu.RLock() + defer fc.mu.RUnlock() + return fc.closed +} + +// Sandboxes returns the sandbox sub-client. +func (fc *Client) Sandboxes() v1.SandboxInterface { return fc.sandboxes } + +// Providers returns the provider sub-client. +func (fc *Client) Providers() v1.ProviderInterface { return fc.providers } + +// Services returns the service sub-client. +func (fc *Client) Services() v1.ServiceInterface { return fc.services } + +// Exec returns the exec sub-client. +func (fc *Client) Exec() v1.ExecInterface { return fc.exec } + +// Files returns the file sub-client. +func (fc *Client) Files() v1.FileInterface { return fc.files } + +// Health returns the health sub-client. +func (fc *Client) Health() v1.HealthInterface { return fc.health } + +// SSH returns the SSH session sub-client. +func (fc *Client) SSH() v1.SSHInterface { return fc.ssh } + +// TCP returns the TCP port forwarding sub-client. +func (fc *Client) TCP() v1.TCPInterface { return fc.tcp } + +// Config returns the configuration sub-client. +func (fc *Client) Config() v1.ConfigInterface { return fc.cfg } + +// Policy returns the policy management sub-client. +func (fc *Client) Policy() v1.PolicyInterface { return fc.policy } + +// Workspaces returns the workspace management sub-client. +func (fc *Client) Workspaces() v1.WorkspaceInterface { return fc.workspaces } + +// Inference returns the inference route management sub-client. +func (fc *Client) Inference() v1.InferenceInterface { return fc.inference } + +// Close marks the client as closed, stops all active watchers, and causes +// subsequent sub-client calls to return Unavailable. Safe to call multiple +// times. +func (fc *Client) Close() error { + fc.closeOnce.Do(func() { + fc.mu.Lock() + fc.closed = true + fc.mu.Unlock() + + fc.sandboxBroadcaster.StopAll() + }) + return nil +} + +// AddSandbox inserts a sandbox directly into the store without triggering +// watch events. This is intended for pre-seeding test fixtures before the +// test begins. The sandbox is deep-copied on insert. +func (fc *Client) AddSandbox(workspace string, sb *types.Sandbox) { + if sb == nil { + return + } + fc.sandboxStore.Insert(workspace, sb) +} + +// AddProvider inserts a provider directly into the store without triggering +// any side effects. This is intended for pre-seeding test fixtures before +// the test begins. The provider is deep-copied on insert. +func (fc *Client) AddProvider(workspace string, p *types.Provider) { + if p == nil { + return + } + fc.providerStore.Insert(workspace, p) +} + +// AddWorkspace inserts a workspace directly into the store without triggering +// any side effects. This is intended for pre-seeding test fixtures before +// the test begins. The workspace is deep-copied on insert. +func (fc *Client) AddWorkspace(ws *types.Workspace) { + if ws == nil { + return + } + fc.workspaceStore.Insert("", ws) +} + +// AddMember inserts a workspace member directly into the store without +// triggering any side effects. This is intended for pre-seeding test fixtures +// before the test begins. The member is deep-copied on insert. +func (fc *Client) AddMember(workspace string, m *types.WorkspaceMember) { + if m == nil { + return + } + fc.memberStore.Insert(workspace, m) +} + +// AddGlobalRevision adds a gateway-global policy revision for test seeding. +// Global revisions are returned by Policy().List() and Policy().GetStatus() +// when the global option is enabled. +func (fc *Client) AddGlobalRevision(rev types.SandboxPolicyRevision) { + fc.policy.(*fakePolicyClient).AddGlobalRevision(rev) +} + +// AddRevision adds a sandbox-scoped policy revision for test seeding. +// Sandbox revisions are returned by Policy().List() and Policy().GetStatus() +// when the global option is not set. +func (fc *Client) AddRevision(workspace, name string, rev types.SandboxPolicyRevision) { + fc.policy.(*fakePolicyClient).AddRevision(workspace, name, rev) +} + +// Compile-time interface check. +var _ v1.ClientInterface = (*Client)(nil) diff --git a/sdk/go/openshell/v1/fake/fake_test.go b/sdk/go/openshell/v1/fake/fake_test.go new file mode 100644 index 0000000000..d749b1eac4 --- /dev/null +++ b/sdk/go/openshell/v1/fake/fake_test.go @@ -0,0 +1,231 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T025: FakeClient Close tests --- + +func TestFakeClient_Close(t *testing.T) { + fc := NewClient() + + err := fc.Close() + require.NoError(t, err) +} + +func TestFakeClient_Close_Idempotent(t *testing.T) { + fc := NewClient() + + err := fc.Close() + require.NoError(t, err) + + err = fc.Close() + require.NoError(t, err) +} + +func TestFakeClient_Sandboxes_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Sandboxes().Create(ctx, "default", "test", &types.SandboxSpec{}, nil) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Providers_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Providers().Create(ctx, "default", &types.Provider{Name: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Health_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Health().Check(ctx) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Exec_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + _, err := fc.Exec().Run(ctx, "default", "sandbox", []string{"echo"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Files_AfterClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + _ = fc.Close() + + err := fc.Files().Upload(ctx, "default", "sandbox", "/local", "/remote") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeClient_Watch_StoppedOnClose(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + w, err := fc.Sandboxes().Watch(ctx, "default", "") + require.NoError(t, err) + + _ = fc.Close() + + // Channel should be closed after FakeClient.Close + _, ok := <-w.ResultChan() + assert.False(t, ok, "watcher channel should be closed after FakeClient.Close") +} + +func TestFakeClient_WithHealthResult(t *testing.T) { + custom := &types.HealthResult{Healthy: false, Version: "broken"} + fc := NewClient(WithHealthResult(custom)) + ctx := context.Background() + + result, err := fc.Health().Check(ctx) + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "broken", result.Version) +} + +func TestFakeClient_SubClients(t *testing.T) { + fc := NewClient() + + assert.NotNil(t, fc.Sandboxes()) + assert.NotNil(t, fc.Providers()) + assert.NotNil(t, fc.Exec()) + assert.NotNil(t, fc.Files()) + assert.NotNil(t, fc.Health()) +} + +// --- T013: Pre-seed tests --- + +func TestFakeClient_AddSandbox(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + sb := &types.Sandbox{ + Name: "pre-seeded", + Spec: types.SandboxSpec{LogLevel: "debug"}, + Status: types.SandboxStatus{ + Phase: types.SandboxReady, + }, + } + + fc.AddSandbox("default", sb) + + got, err := fc.Sandboxes().Get(ctx, "default", "pre-seeded") + require.NoError(t, err) + assert.Equal(t, "pre-seeded", got.Name) + assert.Equal(t, "debug", got.Spec.LogLevel) + assert.Equal(t, types.SandboxReady, got.Status.Phase) +} + +func TestFakeClient_AddSandbox_InList(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + fc.AddSandbox("default", &types.Sandbox{Name: "sb-1"}) + fc.AddSandbox("default", &types.Sandbox{Name: "sb-2"}) + + list, err := fc.Sandboxes().List(ctx, "default") + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestFakeClient_AddSandbox_NoWatchEvents(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + w, err := fc.Sandboxes().Watch(ctx, "default", "") + require.NoError(t, err) + defer w.Stop() + + fc.AddSandbox("default", &types.Sandbox{Name: "pre-seeded"}) + + // No event should be received — AddSandbox bypasses the broadcaster + select { + case ev := <-w.ResultChan(): + t.Fatalf("unexpected event: %v", ev) + default: + // Good — no event received + } +} + +func TestFakeClient_AddSandbox_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + sb := &types.Sandbox{ + Name: "pre-seeded", + Labels: map[string]string{"env": "test"}, + } + fc.AddSandbox("default", sb) + + // Mutate the input + sb.Labels["env"] = "mutated" + + got, err := fc.Sandboxes().Get(ctx, "default", "pre-seeded") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) +} + +func TestFakeClient_AddProvider(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + + fc.AddProvider("default", p) + + got, err := fc.Providers().Get(ctx, "default", "openai") + require.NoError(t, err) + assert.Equal(t, "openai", got.Name) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} + +func TestFakeClient_AddProvider_DeepCopy(t *testing.T) { + fc := NewClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + fc.AddProvider("default", p) + + // Mutate input + p.Spec.Config["model"] = "mutated" + + got, err := fc.Providers().Get(ctx, "default", "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} diff --git a/sdk/go/openshell/v1/fake/file.go b/sdk/go/openshell/v1/fake/file.go new file mode 100644 index 0000000000..52b234219d --- /dev/null +++ b/sdk/go/openshell/v1/fake/file.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var _ v1.FileInterface = (*fakeFileClient)(nil) + +// fakeFileClient implements v1.FileInterface. All methods return +// Unimplemented because file transfer requires a real sandbox runtime. +type fakeFileClient struct { + closedFunc func() bool +} + +// newFakeFileClient creates a new fakeFileClient. +func newFakeFileClient(closedFunc func() bool) *fakeFileClient { + return &fakeFileClient{closedFunc: closedFunc} +} + +// Upload returns Unimplemented. +func (c *fakeFileClient) Upload(_ context.Context, _, sandboxName, _, remotePath string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePath == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "remote path must not be empty"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Upload is not supported by the fake client"} +} + +// Download returns Unimplemented. +func (c *fakeFileClient) Download(_ context.Context, _, sandboxName, remotePath, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePath == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "remote path must not be empty"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Download is not supported by the fake client"} +} diff --git a/sdk/go/openshell/v1/fake/file_test.go b/sdk/go/openshell/v1/fake/file_test.go new file mode 100644 index 0000000000..05a991bfea --- /dev/null +++ b/sdk/go/openshell/v1/fake/file_test.go @@ -0,0 +1,52 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T022: File stub tests --- + +func TestFile_Upload_Unimplemented(t *testing.T) { + fc := newFakeFileClient(func() bool { return false }) + ctx := context.Background() + + err := fc.Upload(ctx, "default", "test-sandbox", "/local/file.txt", "/remote/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFile_Download_Unimplemented(t *testing.T) { + fc := newFakeFileClient(func() bool { return false }) + ctx := context.Background() + + err := fc.Download(ctx, "default", "test-sandbox", "/remote/file.txt", "/local/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFile_Upload_ClosedClient(t *testing.T) { + fc := newFakeFileClient(func() bool { return true }) + ctx := context.Background() + + err := fc.Upload(ctx, "default", "test-sandbox", "/local/file.txt", "/remote/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFile_Download_ClosedClient(t *testing.T) { + fc := newFakeFileClient(func() bool { return true }) + ctx := context.Background() + + err := fc.Download(ctx, "default", "test-sandbox", "/remote/file.txt", "/local/file.txt") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/health.go b/sdk/go/openshell/v1/fake/health.go new file mode 100644 index 0000000000..aa581ecb9c --- /dev/null +++ b/sdk/go/openshell/v1/fake/health.go @@ -0,0 +1,103 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeHealthClient implements v1.HealthInterface with configurable +// responses. When no custom result is provided, Check returns a +// default healthy response. +type fakeHealthClient struct { + result *types.HealthResult + gatewayInfo *types.GatewayInfo + currentUser *types.CurrentUser + closedFunc func() bool +} + +// newFakeHealthClient creates a new fakeHealthClient. If result is nil, +// Check will return the default healthy response. +func newFakeHealthClient(result *types.HealthResult, closedFunc func() bool) *fakeHealthClient { + return &fakeHealthClient{ + result: result, + closedFunc: closedFunc, + } +} + +// Check returns the configured health result. If no custom result was +// provided, it returns {Healthy: true, Version: "fake"}. +func (c *fakeHealthClient) Check(_ context.Context) (*types.HealthResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if c.result != nil { + cp := *c.result + return &cp, nil + } + + return &types.HealthResult{ + Healthy: true, + Version: "fake", + }, nil +} + +// GetGatewayInfo returns the configured gateway info. If no custom info +// was provided, it returns a default healthy response. +func (c *fakeHealthClient) GetGatewayInfo(_ context.Context) (*types.GatewayInfo, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if c.gatewayInfo != nil { + return copyGatewayInfo(c.gatewayInfo), nil + } + + return &types.GatewayInfo{ + Status: types.ServiceStatusHealthy, + Version: "fake", + }, nil +} + +// GetCurrentUser returns the configured current user. If no custom user +// was provided, it returns a default user. +func (c *fakeHealthClient) GetCurrentUser(_ context.Context) (*types.CurrentUser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if c.currentUser != nil { + return copyCurrentUser(c.currentUser), nil + } + + return &types.CurrentUser{ + Subject: "fake-user", + DisplayName: "Fake User", + }, nil +} + +func copyGatewayInfo(info *types.GatewayInfo) *types.GatewayInfo { + if info == nil { + return nil + } + cp := *info + if info.ComputeDrivers != nil { + cp.ComputeDrivers = make([]types.ComputeDriverInfo, len(info.ComputeDrivers)) + copy(cp.ComputeDrivers, info.ComputeDrivers) + } + return &cp +} + +func copyCurrentUser(user *types.CurrentUser) *types.CurrentUser { + if user == nil { + return nil + } + cp := *user + cp.Roles = copyStringSlice(user.Roles) + cp.Scopes = copyStringSlice(user.Scopes) + return &cp +} diff --git a/sdk/go/openshell/v1/fake/health_test.go b/sdk/go/openshell/v1/fake/health_test.go new file mode 100644 index 0000000000..8a5ddeba75 --- /dev/null +++ b/sdk/go/openshell/v1/fake/health_test.go @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T017: Health check tests --- + +func TestHealth_DefaultHealthy(t *testing.T) { + hc := newFakeHealthClient(nil, func() bool { return false }) + ctx := context.Background() + + result, err := hc.Check(ctx) + require.NoError(t, err) + assert.True(t, result.Healthy) + assert.Equal(t, "fake", result.Version) +} + +func TestHealth_ConfigurableResult(t *testing.T) { + custom := &types.HealthResult{ + Healthy: false, + Version: "v0.0.0-broken", + } + hc := newFakeHealthClient(custom, func() bool { return false }) + ctx := context.Background() + + result, err := hc.Check(ctx) + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "v0.0.0-broken", result.Version) +} + +func TestHealth_ClosedClient(t *testing.T) { + hc := newFakeHealthClient(nil, func() bool { return true }) + ctx := context.Background() + + _, err := hc.Check(ctx) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestHealth_GetGatewayInfo_Default(t *testing.T) { + fc := NewClient() + info, err := fc.Health().GetGatewayInfo(context.Background()) + + require.NoError(t, err) + require.NotNil(t, info) + assert.Equal(t, types.ServiceStatusHealthy, info.Status) + assert.Equal(t, "fake", info.Version) +} + +func TestHealth_GetGatewayInfo_Custom(t *testing.T) { + fc := NewClient(WithGatewayInfo(&types.GatewayInfo{ + Status: types.ServiceStatusDegraded, + Version: "1.2.3", + ComputeDrivers: []types.ComputeDriverInfo{ + {Name: "k8s", DriverName: "kubernetes", DriverVersion: "2.0.0"}, + }, + })) + + info, err := fc.Health().GetGatewayInfo(context.Background()) + + require.NoError(t, err) + require.NotNil(t, info) + assert.Equal(t, types.ServiceStatusDegraded, info.Status) + assert.Equal(t, "1.2.3", info.Version) + require.Len(t, info.ComputeDrivers, 1) + assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) +} + +func TestHealth_GetGatewayInfo_DeepCopy(t *testing.T) { + fc := NewClient(WithGatewayInfo(&types.GatewayInfo{ + Status: types.ServiceStatusHealthy, + Version: "1.0.0", + ComputeDrivers: []types.ComputeDriverInfo{ + {Name: "k8s"}, + }, + })) + + info1, _ := fc.Health().GetGatewayInfo(context.Background()) + info1.ComputeDrivers[0].Name = "mutated" + + info2, _ := fc.Health().GetGatewayInfo(context.Background()) + assert.Equal(t, "k8s", info2.ComputeDrivers[0].Name) +} + +func TestHealth_GetCurrentUser_Default(t *testing.T) { + fc := NewClient() + user, err := fc.Health().GetCurrentUser(context.Background()) + + require.NoError(t, err) + require.NotNil(t, user) + assert.Equal(t, "fake-user", user.Subject) + assert.Equal(t, "Fake User", user.DisplayName) +} + +func TestHealth_GetCurrentUser_Custom(t *testing.T) { + fc := NewClient(WithCurrentUser(&types.CurrentUser{ + Subject: "real-user", + DisplayName: "Real User", + Roles: []string{"admin"}, + Scopes: []string{"read", "write"}, + IdentityProvider: "oidc", + })) + + user, err := fc.Health().GetCurrentUser(context.Background()) + + require.NoError(t, err) + require.NotNil(t, user) + assert.Equal(t, "real-user", user.Subject) + assert.Equal(t, "Real User", user.DisplayName) + assert.Equal(t, []string{"admin"}, user.Roles) + assert.Equal(t, []string{"read", "write"}, user.Scopes) + assert.Equal(t, "oidc", user.IdentityProvider) +} + +func TestHealth_GetCurrentUser_DeepCopy(t *testing.T) { + fc := NewClient(WithCurrentUser(&types.CurrentUser{ + Subject: "user", + Roles: []string{"admin"}, + })) + + user1, _ := fc.Health().GetCurrentUser(context.Background()) + user1.Roles[0] = "mutated" + + user2, _ := fc.Health().GetCurrentUser(context.Background()) + assert.Equal(t, "admin", user2.Roles[0]) +} + +func TestHealth_GetGatewayInfo_Closed(t *testing.T) { + fc := NewClient() + _ = fc.Close() + + _, err := fc.Health().GetGatewayInfo(context.Background()) + assert.True(t, types.IsUnavailable(err)) +} + +func TestHealth_GetCurrentUser_Closed(t *testing.T) { + fc := NewClient() + _ = fc.Close() + + _, err := fc.Health().GetCurrentUser(context.Background()) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/inference.go b/sdk/go/openshell/v1/fake/inference.go new file mode 100644 index 0000000000..cb4ef77e54 --- /dev/null +++ b/sdk/go/openshell/v1/fake/inference.go @@ -0,0 +1,119 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +type fakeInferenceClient struct { + mu sync.RWMutex + routes map[string]*types.InferenceRoute // keyed by "workspace/routeName" + closedFunc func() bool +} + +func newFakeInferenceClient(closedFunc func() bool) *fakeInferenceClient { + return &fakeInferenceClient{ + routes: make(map[string]*types.InferenceRoute), + closedFunc: closedFunc, + } +} + +func inferenceKey(workspace, routeName string) string { + return workspace + "/" + routeName +} + +func copyInferenceRoute(r *types.InferenceRoute) *types.InferenceRoute { + if r == nil { + return nil + } + cp := *r + if r.ValidatedEndpoints != nil { + cp.ValidatedEndpoints = make([]types.ValidatedEndpoint, len(r.ValidatedEndpoints)) + copy(cp.ValidatedEndpoints, r.ValidatedEndpoints) + } + return &cp +} + +func (c *fakeInferenceClient) SetRoute(_ context.Context, workspace string, config *types.InferenceRouteConfig) (*types.InferenceRoute, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if workspace == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace must not be empty"} + } + if config == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "config must not be nil"} + } + if config.ProviderName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider name must not be empty"} + } + if config.ModelID == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "model ID must not be empty"} + } + + key := inferenceKey(workspace, config.RouteName) + + c.mu.Lock() + defer c.mu.Unlock() + + // Determine version: increment if route exists, start at 1 otherwise. + var version uint64 = 1 + if existing, ok := c.routes[key]; ok { + version = existing.Version + 1 + } + + route := &types.InferenceRoute{ + ProviderName: config.ProviderName, + ModelID: config.ModelID, + Version: version, + RouteName: config.RouteName, + TimeoutSecs: config.TimeoutSecs, + Workspace: workspace, + } + + c.routes[key] = copyInferenceRoute(route) + return copyInferenceRoute(route), nil +} + +func (c *fakeInferenceClient) GetRoute(_ context.Context, workspace, routeName string) (*types.InferenceRoute, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if workspace == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace must not be empty"} + } + + key := inferenceKey(workspace, routeName) + + c.mu.RLock() + defer c.mu.RUnlock() + + route, ok := c.routes[key] + if !ok { + return nil, &types.StatusError{Code: types.ErrorNotFound, Message: "route not found"} + } + return copyInferenceRoute(route), nil +} + +func (c *fakeInferenceClient) DeleteRoute(_ context.Context, workspace, routeName string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if workspace == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace must not be empty"} + } + + key := inferenceKey(workspace, routeName) + + c.mu.Lock() + defer c.mu.Unlock() + + // Idempotent: deleting a non-existent route is not an error. + delete(c.routes, key) + return nil +} diff --git a/sdk/go/openshell/v1/fake/inference_test.go b/sdk/go/openshell/v1/fake/inference_test.go new file mode 100644 index 0000000000..21e69520f9 --- /dev/null +++ b/sdk/go/openshell/v1/fake/inference_test.go @@ -0,0 +1,273 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestFakeInference_SetRoute_Success(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + route, err := fc.Inference().SetRoute(context.Background(), "ws", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "my-route", + TimeoutSecs: 120, + }) + + require.NoError(t, err) + require.NotNil(t, route) + assert.Equal(t, "openai", route.ProviderName) + assert.Equal(t, "gpt-4", route.ModelID) + assert.Equal(t, uint64(1), route.Version) + assert.Equal(t, "my-route", route.RouteName) + assert.Equal(t, uint64(120), route.TimeoutSecs) + assert.Equal(t, "ws", route.Workspace) +} + +func TestFakeInference_SetRoute_UpdateIncrementsVersion(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + ctx := context.Background() + + route1, err := fc.Inference().SetRoute(ctx, "ws", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "my-route", + }) + require.NoError(t, err) + assert.Equal(t, uint64(1), route1.Version) + + route2, err := fc.Inference().SetRoute(ctx, "ws", &types.InferenceRouteConfig{ + ProviderName: "anthropic", + ModelID: "claude-4", + RouteName: "my-route", + }) + require.NoError(t, err) + assert.Equal(t, uint64(2), route2.Version) + assert.Equal(t, "anthropic", route2.ProviderName) +} + +func TestFakeInference_SetRoute_EmptyWorkspace(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + _, err := fc.Inference().SetRoute(context.Background(), "", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + }) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeInference_SetRoute_NilConfig(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + _, err := fc.Inference().SetRoute(context.Background(), "ws", nil) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeInference_SetRoute_EmptyProviderName(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + _, err := fc.Inference().SetRoute(context.Background(), "ws", &types.InferenceRouteConfig{ + ProviderName: "", + ModelID: "gpt-4", + }) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeInference_SetRoute_EmptyModelID(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + _, err := fc.Inference().SetRoute(context.Background(), "ws", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "", + }) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeInference_SetRoute_EmptyRouteName(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + route, err := fc.Inference().SetRoute(context.Background(), "ws", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "", + }) + + require.NoError(t, err) + require.NotNil(t, route) + assert.Empty(t, route.RouteName) +} + +func TestFakeInference_GetRoute_Success(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := fc.Inference().SetRoute(ctx, "ws", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "my-route", + TimeoutSecs: 120, + }) + require.NoError(t, err) + + route, err := fc.Inference().GetRoute(ctx, "ws", "my-route") + + require.NoError(t, err) + require.NotNil(t, route) + assert.Equal(t, "openai", route.ProviderName) + assert.Equal(t, "gpt-4", route.ModelID) + assert.Equal(t, "my-route", route.RouteName) + assert.Equal(t, uint64(120), route.TimeoutSecs) + assert.Equal(t, "ws", route.Workspace) +} + +func TestFakeInference_GetRoute_EmptyWorkspace(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + _, err := fc.Inference().GetRoute(context.Background(), "", "my-route") + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeInference_GetRoute_NotFound(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + _, err := fc.Inference().GetRoute(context.Background(), "ws", "nonexistent") + + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestFakeInference_GetRoute_DeepCopy(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := fc.Inference().SetRoute(ctx, "ws", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "my-route", + }) + require.NoError(t, err) + + route1, err := fc.Inference().GetRoute(ctx, "ws", "my-route") + require.NoError(t, err) + + // Mutate the returned route; it should not affect the stored copy. + route1.ProviderName = "mutated" + + route2, err := fc.Inference().GetRoute(ctx, "ws", "my-route") + require.NoError(t, err) + assert.Equal(t, "openai", route2.ProviderName) +} + +func TestFakeInference_DeleteRoute_Success(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := fc.Inference().SetRoute(ctx, "ws", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "my-route", + }) + require.NoError(t, err) + + err = fc.Inference().DeleteRoute(ctx, "ws", "my-route") + require.NoError(t, err) + + // Subsequent get should return NotFound. + _, err = fc.Inference().GetRoute(ctx, "ws", "my-route") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestFakeInference_DeleteRoute_EmptyWorkspace(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + err := fc.Inference().DeleteRoute(context.Background(), "", "my-route") + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeInference_DeleteRoute_Idempotent(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + // Deleting a non-existent route should not error. + err := fc.Inference().DeleteRoute(context.Background(), "ws", "nonexistent") + require.NoError(t, err) +} + +func TestFakeInference_WorkspaceIsolation(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + ctx := context.Background() + + _, err := fc.Inference().SetRoute(ctx, "ws1", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "shared-name", + }) + require.NoError(t, err) + + // Different workspace should not see the route. + _, err = fc.Inference().GetRoute(ctx, "ws2", "shared-name") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestFakeInference_ClosedClient(t *testing.T) { + fc := NewClient() + _ = fc.Close() + + ctx := context.Background() + + _, err := fc.Inference().SetRoute(ctx, "ws", &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) + + _, err = fc.Inference().GetRoute(ctx, "ws", "route") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) + + err = fc.Inference().DeleteRoute(ctx, "ws", "route") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/policy.go b/sdk/go/openshell/v1/fake/policy.go new file mode 100644 index 0000000000..367ebf2226 --- /dev/null +++ b/sdk/go/openshell/v1/fake/policy.go @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "maps" + "slices" + "strings" + "sync" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +func copySandboxPolicyRevision(r types.SandboxPolicyRevision) types.SandboxPolicyRevision { + if r.Policy != nil { + cp := *r.Policy + if r.Policy.NetworkPolicies != nil { + cp.NetworkPolicies = make(map[string]types.NetworkPolicyRule, len(r.Policy.NetworkPolicies)) + maps.Copy(cp.NetworkPolicies, r.Policy.NetworkPolicies) + } + r.Policy = &cp + } + return r +} + +// fakePolicyClient implements v1.PolicyInterface. List and GetStatus support +// in-memory global and sandbox-scoped revisions. Other methods return +// Unimplemented because policy management requires a real gateway. +type fakePolicyClient struct { + mu sync.RWMutex + closedFunc func() bool + + // globalRevisions stores gateway-global policy revisions. + globalRevisions []types.SandboxPolicyRevision + // sandboxRevisions stores sandbox-scoped revisions keyed by "workspace/name". + sandboxRevisions map[string][]types.SandboxPolicyRevision +} + +// newFakePolicyClient creates a new fakePolicyClient. +func newFakePolicyClient(closedFunc func() bool) *fakePolicyClient { + return &fakePolicyClient{ + closedFunc: closedFunc, + sandboxRevisions: make(map[string][]types.SandboxPolicyRevision), + } +} + +// AddGlobalRevision adds a global policy revision for test seeding. +func (c *fakePolicyClient) AddGlobalRevision(rev types.SandboxPolicyRevision) { + c.mu.Lock() + defer c.mu.Unlock() + c.globalRevisions = append(c.globalRevisions, copySandboxPolicyRevision(rev)) +} + +// AddRevision adds a sandbox-scoped policy revision for test seeding. +func (c *fakePolicyClient) AddRevision(workspace, name string, rev types.SandboxPolicyRevision) { + c.mu.Lock() + defer c.mu.Unlock() + key := workspace + "/" + name + c.sandboxRevisions[key] = append(c.sandboxRevisions[key], copySandboxPolicyRevision(rev)) +} + +// GetDraft returns Unimplemented. +func (c *fakePolicyClient) GetDraft(_ context.Context, _, _ string, _ ...v1.GetDraftOption) (*types.DraftPolicy, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetDraft is not supported by the fake client"} +} + +// ApproveDraftChunk returns Unimplemented. +func (c *fakePolicyClient) ApproveDraftChunk(_ context.Context, _, _, _ string) (*types.ApproveResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ApproveDraftChunk is not supported by the fake client"} +} + +// RejectDraftChunk returns Unimplemented. +func (c *fakePolicyClient) RejectDraftChunk(_ context.Context, _, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "RejectDraftChunk is not supported by the fake client"} +} + +// ApproveAllDraftChunks returns Unimplemented. +func (c *fakePolicyClient) ApproveAllDraftChunks(_ context.Context, _, _ string, _ ...v1.ApproveAllOption) (*types.ApproveAllResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ApproveAllDraftChunks is not supported by the fake client"} +} + +// ClearDraftChunks returns Unimplemented. +func (c *fakePolicyClient) ClearDraftChunks(_ context.Context, _, _ string) (*types.ClearResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "ClearDraftChunks is not supported by the fake client"} +} + +// GetDraftHistory returns Unimplemented. +func (c *fakePolicyClient) GetDraftHistory(_ context.Context, _, _ string) ([]types.DraftHistoryEntry, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetDraftHistory is not supported by the fake client"} +} + +// GetStatus returns the status of a policy revision. When the global option is +// set, it queries global revisions; otherwise it queries sandbox-scoped ones. +func (c *fakePolicyClient) GetStatus(_ context.Context, workspace, sandboxName string, opts ...v1.GetStatusOption) (*types.PolicyStatusResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + cfg := types.ApplyGetStatusOptions(opts) + + c.mu.RLock() + defer c.mu.RUnlock() + + var revisions []types.SandboxPolicyRevision + if cfg.Global() { + revisions = c.globalRevisions + } else { + key := workspace + "/" + sandboxName + revisions = c.sandboxRevisions[key] + } + + if len(revisions) == 0 { + return nil, &types.StatusError{Code: types.ErrorNotFound, Message: "no policy revisions found"} + } + + // Find the active version (highest version with Loaded status). + var activeVersion uint32 + for _, r := range revisions { + if r.Status == types.PolicyLoadStatusLoaded && r.Version > activeVersion { + activeVersion = r.Version + } + } + // If no loaded version, use the highest version. + var maxVersion uint32 + var maxIdx int + for i, r := range revisions { + if r.Version > maxVersion { + maxVersion = r.Version + maxIdx = i + } + } + if activeVersion == 0 { + activeVersion = maxVersion + } + + // Find the requested revision. + targetVersion := cfg.Version() + if targetVersion == 0 { + // Latest revision (by highest version, not insertion order). + rev := copySandboxPolicyRevision(revisions[maxIdx]) + return &types.PolicyStatusResult{Revision: rev, ActiveVersion: activeVersion}, nil + } + + for _, r := range revisions { + if r.Version == targetVersion { + rev := copySandboxPolicyRevision(r) + return &types.PolicyStatusResult{Revision: rev, ActiveVersion: activeVersion}, nil + } + } + + return nil, &types.StatusError{Code: types.ErrorNotFound, Message: "policy version not found"} +} + +// List returns policy revisions. When the global option is set, it returns +// global revisions; otherwise it returns all sandbox-scoped revisions for the +// given workspace. +func (c *fakePolicyClient) List(_ context.Context, workspace string, opts ...v1.ListPolicyOption) ([]types.SandboxPolicyRevision, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + cfg := types.ApplyListPolicyOptions(opts) + + c.mu.RLock() + defer c.mu.RUnlock() + + var revisions []types.SandboxPolicyRevision + if cfg.Global() { + revisions = slices.Clone(c.globalRevisions) + } else { + // Collect all revisions for sandboxes in this workspace. + prefix := workspace + "/" + for key, revs := range c.sandboxRevisions { + if strings.HasPrefix(key, prefix) { + revisions = append(revisions, revs...) + } + } + } + + if len(revisions) == 0 { + return nil, nil + } + + // Sort by version for deterministic ordering (map iteration is random). + slices.SortFunc(revisions, func(a, b types.SandboxPolicyRevision) int { + if a.Version < b.Version { + return -1 + } + if a.Version > b.Version { + return 1 + } + return 0 + }) + + // Apply pagination. + offset := int(cfg.Offset()) + if offset >= len(revisions) { + return nil, nil + } + revisions = revisions[offset:] + + if limit := int(cfg.Limit()); limit > 0 && limit < len(revisions) { + revisions = revisions[:limit] + } + + result := make([]types.SandboxPolicyRevision, len(revisions)) + for i, r := range revisions { + result[i] = copySandboxPolicyRevision(r) + } + return result, nil +} + +// EditDraftChunk returns Unimplemented. +func (c *fakePolicyClient) EditDraftChunk(_ context.Context, _, _, _ string, _ *types.NetworkPolicyRule) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "EditDraftChunk is not supported by the fake client"} +} + +// UndoDraftChunk returns Unimplemented. +func (c *fakePolicyClient) UndoDraftChunk(_ context.Context, _, _, _ string) (*types.UndoResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "UndoDraftChunk is not supported by the fake client"} +} + +// Compile-time check that fakePolicyClient implements v1.PolicyInterface. +var _ v1.PolicyInterface = (*fakePolicyClient)(nil) diff --git a/sdk/go/openshell/v1/fake/policy_test.go b/sdk/go/openshell/v1/fake/policy_test.go new file mode 100644 index 0000000000..65dfe52904 --- /dev/null +++ b/sdk/go/openshell/v1/fake/policy_test.go @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T031: fakePolicyClient stub tests --- + +func TestFakePolicy_GetDraft_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetDraft(context.Background(), "default", "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ApproveDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ApproveDraftChunk(context.Background(), "default", "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_RejectDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + err := c.RejectDraftChunk(context.Background(), "default", "sb-1", "chunk-1", "bad rule") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ApproveAllDraftChunks_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ApproveAllDraftChunks(context.Background(), "default", "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_ClearDraftChunks_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.ClearDraftChunks(context.Background(), "default", "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_GetDraftHistory_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetDraftHistory(context.Background(), "default", "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_GetStatus_EmptyReturnsNotFound(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.GetStatus(context.Background(), "default", "sb-1") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestFakePolicy_List_EmptyReturnsNil(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + revisions, err := c.List(context.Background(), "default") + require.NoError(t, err) + assert.Nil(t, revisions) +} + +func TestFakePolicy_EditDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + err := c.EditDraftChunk(context.Background(), "default", "sb-1", "chunk-1", &types.NetworkPolicyRule{Name: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakePolicy_UndoDraftChunk_ReturnsUnimplemented(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + _, err := c.UndoDraftChunk(context.Background(), "default", "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +// --- T007: Global policy List and GetStatus tests --- + +func TestFakePolicy_List_Global(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + + // Seed global and sandbox-scoped revisions. + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:global-v1", Status: types.PolicyLoadStatusLoaded}) + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 2, PolicyHash: "sha256:global-v2", Status: types.PolicyLoadStatusPending}) + c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:sb-v1", Status: types.PolicyLoadStatusLoaded}) + + // List global revisions. + revisions, err := c.List(context.Background(), "", types.WithListGlobal(true)) + require.NoError(t, err) + require.Len(t, revisions, 2) + assert.Equal(t, uint32(1), revisions[0].Version) + assert.Equal(t, "sha256:global-v1", revisions[0].PolicyHash) + assert.Equal(t, uint32(2), revisions[1].Version) +} + +func TestFakePolicy_List_Sandbox(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + + // Seed global and sandbox-scoped revisions. + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:global-v1"}) + c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:sb-v1", Status: types.PolicyLoadStatusLoaded}) + c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 2, PolicyHash: "sha256:sb-v2", Status: types.PolicyLoadStatusPending}) + + // List sandbox-scoped revisions (no global flag). + revisions, err := c.List(context.Background(), "default") + require.NoError(t, err) + require.Len(t, revisions, 2) + assert.Equal(t, "sha256:sb-v1", revisions[0].PolicyHash) + assert.Equal(t, "sha256:sb-v2", revisions[1].PolicyHash) +} + +func TestFakePolicy_List_NoIsolationCrossContamination(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + + // Only seed sandbox-scoped revisions. + c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:sb-v1"}) + + // Global list returns empty (no global revisions seeded). + revisions, err := c.List(context.Background(), "", types.WithListGlobal(true)) + require.NoError(t, err) + assert.Nil(t, revisions) +} + +func TestFakePolicy_List_GlobalWithPagination(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 1}) + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 2}) + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 3}) + + // Limit to 2. + revisions, err := c.List(context.Background(), "", types.WithListGlobal(true), types.WithLimit(2)) + require.NoError(t, err) + require.Len(t, revisions, 2) + assert.Equal(t, uint32(1), revisions[0].Version) + assert.Equal(t, uint32(2), revisions[1].Version) + + // Offset by 1, limit 2. + revisions, err = c.List(context.Background(), "", types.WithListGlobal(true), types.WithLimit(2), types.WithOffset(1)) + require.NoError(t, err) + require.Len(t, revisions, 2) + assert.Equal(t, uint32(2), revisions[0].Version) + assert.Equal(t, uint32(3), revisions[1].Version) +} + +func TestFakePolicy_GetStatus_Sandbox(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + + c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:sb-v1", Status: types.PolicyLoadStatusLoaded}) + c.AddRevision("default", "sb-1", types.SandboxPolicyRevision{Version: 2, PolicyHash: "sha256:sb-v2", Status: types.PolicyLoadStatusPending}) + + result, err := c.GetStatus(context.Background(), "default", "sb-1") + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(2), result.Revision.Version) + assert.Equal(t, "sha256:sb-v2", result.Revision.PolicyHash) + assert.Equal(t, uint32(1), result.ActiveVersion) +} + +func TestFakePolicy_GetStatus_Global(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:global-v1", Status: types.PolicyLoadStatusSuperseded}) + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 2, PolicyHash: "sha256:global-v2", Status: types.PolicyLoadStatusLoaded}) + + // Get global status (latest). + result, err := c.GetStatus(context.Background(), "", "", types.WithStatusGlobal(true)) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(2), result.Revision.Version) + assert.Equal(t, "sha256:global-v2", result.Revision.PolicyHash) + assert.Equal(t, uint32(2), result.ActiveVersion) +} + +func TestFakePolicy_GetStatus_GlobalWithVersion(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 1, PolicyHash: "sha256:global-v1", Status: types.PolicyLoadStatusSuperseded}) + c.AddGlobalRevision(types.SandboxPolicyRevision{Version: 2, PolicyHash: "sha256:global-v2", Status: types.PolicyLoadStatusLoaded}) + + // Get specific global version. + result, err := c.GetStatus(context.Background(), "", "", types.WithStatusGlobal(true), types.WithVersion(1)) + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(1), result.Revision.Version) + assert.Equal(t, types.PolicyLoadStatusSuperseded, result.Revision.Status) + assert.Equal(t, uint32(2), result.ActiveVersion) +} + +func TestFakePolicy_GetStatus_GlobalNotFound(t *testing.T) { + c := newFakePolicyClient(func() bool { return false }) + + // No global revisions seeded. + _, err := c.GetStatus(context.Background(), "", "", types.WithStatusGlobal(true)) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestFakePolicy_DeepCopyWithPolicy(t *testing.T) { + fc := NewClient() + defer fc.Close() //nolint:errcheck + + fc.AddGlobalRevision(types.SandboxPolicyRevision{ + Version: 1, + PolicyHash: "sha256:with-policy", + Status: types.PolicyLoadStatusLoaded, + Policy: &types.SandboxPolicy{ + NetworkPolicies: map[string]types.NetworkPolicyRule{ + "rule-1": {Name: "rule-1"}, + }, + }, + }) + + fc.AddRevision("default", "sb-1", types.SandboxPolicyRevision{ + Version: 1, + PolicyHash: "sha256:sb-policy", + Status: types.PolicyLoadStatusLoaded, + Policy: &types.SandboxPolicy{ + NetworkPolicies: map[string]types.NetworkPolicyRule{ + "rule-2": {Name: "rule-2"}, + }, + }, + }) + + ctx := context.Background() + + // Get global revision and mutate it. + revisions, err := fc.Policy().List(ctx, "", types.WithListGlobal(true)) + require.NoError(t, err) + require.Len(t, revisions, 1) + require.NotNil(t, revisions[0].Policy) + revisions[0].Policy.NetworkPolicies["rule-1"] = types.NetworkPolicyRule{Name: "mutated"} + + // Verify internal state is not corrupted. + revisions2, err := fc.Policy().List(ctx, "", types.WithListGlobal(true)) + require.NoError(t, err) + assert.Equal(t, "rule-1", revisions2[0].Policy.NetworkPolicies["rule-1"].Name) + + // Get sandbox revision via GetStatus and verify deep copy. + status, err := fc.Policy().GetStatus(ctx, "default", "sb-1") + require.NoError(t, err) + require.NotNil(t, status.Revision.Policy) + assert.Equal(t, "rule-2", status.Revision.Policy.NetworkPolicies["rule-2"].Name) +} + +func TestFakePolicy_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.List(context.Background(), "", types.WithListGlobal(true)) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_GetStatus_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.GetStatus(context.Background(), "", "", types.WithStatusGlobal(true)) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- Closed client tests --- + +func TestFakePolicy_GetDraft_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.GetDraft(context.Background(), "default", "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_ApproveDraftChunk_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + _, err := c.ApproveDraftChunk(context.Background(), "default", "sb-1", "chunk-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakePolicy_RejectDraftChunk_ClosedReturnsUnavailable(t *testing.T) { + c := newFakePolicyClient(func() bool { return true }) + err := c.RejectDraftChunk(context.Background(), "default", "sb-1", "chunk-1", "reason") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/profile.go b/sdk/go/openshell/v1/fake/profile.go new file mode 100644 index 0000000000..067a62c627 --- /dev/null +++ b/sdk/go/openshell/v1/fake/profile.go @@ -0,0 +1,73 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeProfileClient implements v1.ProfileInterface. All methods return +// Unimplemented because profile management requires a real server. +type fakeProfileClient struct { + closedFunc func() bool +} + +// newFakeProfileClient creates a new fakeProfileClient. +func newFakeProfileClient(closedFunc func() bool) *fakeProfileClient { + return &fakeProfileClient{closedFunc: closedFunc} +} + +// List returns Unimplemented. +func (c *fakeProfileClient) List(_ context.Context, _ string, _ ...v1.ListOptions) ([]*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// Get returns Unimplemented. +func (c *fakeProfileClient) Get(_ context.Context, _, _ string) (*types.ProviderProfile, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Get is not supported by the fake client"} +} + +// Import returns Unimplemented. +func (c *fakeProfileClient) Import(_ context.Context, _ string, _ []types.ProfileImportItem) (*types.ImportResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Import is not supported by the fake client"} +} + +// Update returns Unimplemented. +func (c *fakeProfileClient) Update(_ context.Context, _, _ string, _ uint64, _ types.ProfileImportItem) (*types.UpdateResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Update is not supported by the fake client"} +} + +// Lint returns Unimplemented. +func (c *fakeProfileClient) Lint(_ context.Context, _ string, _ []types.ProfileImportItem) (*types.LintResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Lint is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeProfileClient) Delete(_ context.Context, _, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeProfileClient implements v1.ProfileInterface. +var _ v1.ProfileInterface = (*fakeProfileClient)(nil) diff --git a/sdk/go/openshell/v1/fake/profile_test.go b/sdk/go/openshell/v1/fake/profile_test.go new file mode 100644 index 0000000000..a0698c1f0b --- /dev/null +++ b/sdk/go/openshell/v1/fake/profile_test.go @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T027: fakeProfileClient stub tests --- + +func TestFakeProfile_List_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.List(context.Background(), "default") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Get_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Get(context.Background(), "default", "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Import_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Import(context.Background(), "default", []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Update_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Update(context.Background(), "default", "profile-1", 1, types.ProfileImportItem{Source: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Lint_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Lint(context.Background(), "default", []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeProfileClient(func() bool { return false }) + _, err := c.Delete(context.Background(), "default", "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeProfile_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.List(context.Background(), "default") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Get_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Get(context.Background(), "default", "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Import_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Import(context.Background(), "default", []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Update_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Update(context.Background(), "default", "profile-1", 1, types.ProfileImportItem{Source: "test"}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Lint_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Lint(context.Background(), "default", []types.ProfileImportItem{{Source: "test"}}) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeProfile_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeProfileClient(func() bool { return true }) + _, err := c.Delete(context.Background(), "default", "profile-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/provider.go b/sdk/go/openshell/v1/fake/provider.go new file mode 100644 index 0000000000..1f5ce4d831 --- /dev/null +++ b/sdk/go/openshell/v1/fake/provider.go @@ -0,0 +1,178 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// providerName extracts the name from a Provider pointer for use as the +// objectStore key function. +func providerName(p *types.Provider) string { + return p.Name +} + +// copyProvider returns a deep copy of a Provider pointer. All maps are +// duplicated to prevent aliasing. +func copyProvider(p *types.Provider) *types.Provider { + if p == nil { + return nil + } + cp := *p + cp.Labels = copyStringMap(p.Labels) + cp.Annotations = copyStringMap(p.Annotations) + if p.DeletionTimestamp != nil { + t := *p.DeletionTimestamp + cp.DeletionTimestamp = &t + } + cp.Spec = copyProviderSpec(p.Spec) + return &cp +} + +func copyProviderSpec(s types.ProviderSpec) types.ProviderSpec { + s.Credentials = copyStringMap(s.Credentials) + s.Config = copyStringMap(s.Config) + s.CredentialExpiresAt = copyTimeMap(s.CredentialExpiresAt) + return s +} + +// copyTimeMap returns a shallow copy of a string-to-time.Time map. +func copyTimeMap(m map[string]time.Time) map[string]time.Time { + if m == nil { + return nil + } + cp := make(map[string]time.Time, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +// fakeProviderClient implements v1.ProviderInterface backed by an in-memory +// objectStore. +type fakeProviderClient struct { + store *objectStore[*types.Provider] + closedFunc func() bool + profiles *fakeProfileClient + refresh *fakeRefreshClient +} + +// newFakeProviderClient creates a new fakeProviderClient. +func newFakeProviderClient( + store *objectStore[*types.Provider], + closedFunc func() bool, +) *fakeProviderClient { + return &fakeProviderClient{ + store: store, + closedFunc: closedFunc, + profiles: newFakeProfileClient(closedFunc), + refresh: newFakeRefreshClient(closedFunc), + } +} + +// Profiles returns a sub-client for provider profile operations. +func (c *fakeProviderClient) Profiles() v1.ProfileInterface { + return c.profiles +} + +// Refresh returns a sub-client for credential refresh operations. +func (c *fakeProviderClient) Refresh() v1.RefreshInterface { + return c.refresh +} + +// Create adds a new provider. CreatedAt and ResourceVersion are set +// automatically. +func (c *fakeProviderClient) Create(_ context.Context, workspace string, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + + p := copyProvider(provider) + p.Workspace = workspace + p.CreatedAt = time.Now() + p.ResourceVersion = 1 + + return c.store.Create(workspace, p) +} + +// Get retrieves a provider by name. +func (c *fakeProviderClient) Get(_ context.Context, workspace, name string) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(workspace, name) +} + +// List returns all providers. ListOptions are accepted for interface +// compatibility but filtering is not implemented. +func (c *fakeProviderClient) List(_ context.Context, workspace string, opts ...v1.ListOptions) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if len(opts) > 0 && opts[0].AllWorkspaces { + return c.store.ListAll(), nil + } + return c.store.List(workspace), nil +} + +// Update replaces an existing provider's data. ResourceVersion is +// incremented automatically. +func (c *fakeProviderClient) Update(_ context.Context, workspace string, provider *types.Provider) (*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + + existing, err := c.store.Get(workspace, provider.Name) + if err != nil { + return nil, err + } + + p := copyProvider(provider) + p.Workspace = workspace + p.CreatedAt = existing.CreatedAt + p.ResourceVersion = existing.ResourceVersion + 1 + + return c.store.Update(workspace, p) +} + +// Delete removes a provider by name. The operation is idempotent. +func (c *fakeProviderClient) Delete(_ context.Context, workspace, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + c.store.Delete(workspace, name) + return nil +} + +// Ensure creates a provider if it does not exist, or updates it if it does. +func (c *fakeProviderClient) Ensure(ctx context.Context, workspace string, provider *types.Provider) (*types.Provider, error) { + if provider == nil { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "provider must not be nil"} + } + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + existing, err := c.store.Get(workspace, provider.Name) + if err != nil { + if types.IsNotFound(err) { + return c.Create(ctx, workspace, provider) + } + return nil, err + } + updated := copyProvider(provider) + updated.ID = existing.ID + updated.ResourceVersion = existing.ResourceVersion + return c.Update(ctx, workspace, updated) +} diff --git a/sdk/go/openshell/v1/fake/provider_test.go b/sdk/go/openshell/v1/fake/provider_test.go new file mode 100644 index 0000000000..4bc6122c65 --- /dev/null +++ b/sdk/go/openshell/v1/fake/provider_test.go @@ -0,0 +1,276 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// helper to build a minimal fake provider client for testing. +func newTestProviderClient() *fakeProviderClient { + store := newobjectStore(providerName, copyProvider) + return newFakeProviderClient(store, func() bool { return false }) +} + +// --- T015: Provider CRUD tests --- + +func TestProvider_Create(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{ + Credentials: map[string]string{"api_key": "sk-test"}, + Config: map[string]string{"model": "gpt-4"}, + }, + } + + result, err := pc.Create(ctx, "default", p) + require.NoError(t, err) + assert.Equal(t, "openai", result.Name) + assert.Equal(t, "openai", result.Type) + assert.Equal(t, "sk-test", result.Spec.Credentials["api_key"]) + assert.NotZero(t, result.CreatedAt) + assert.Equal(t, uint64(1), result.ResourceVersion) +} + +func TestProvider_Create_AlreadyExists(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{Name: "openai", Type: "openai"} + _, err := pc.Create(ctx, "default", p) + require.NoError(t, err) + + _, err = pc.Create(ctx, "default", p) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestProvider_Get(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, "default", &types.Provider{Name: "openai", Type: "openai"}) + + got, err := pc.Get(ctx, "default", "openai") + require.NoError(t, err) + assert.Equal(t, "openai", got.Name) +} + +func TestProvider_Get_NotFound(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, err := pc.Get(ctx, "default", "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_List_Empty(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + list, err := pc.List(ctx, "default") + require.NoError(t, err) + assert.Empty(t, list) +} + +func TestProvider_List(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, "default", &types.Provider{Name: "openai", Type: "openai"}) + _, _ = pc.Create(ctx, "default", &types.Provider{Name: "anthropic", Type: "anthropic"}) + + list, err := pc.List(ctx, "default") + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestProvider_Update(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, "default", &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-3.5"}}, + }) + + updated, err := pc.Update(ctx, "default", &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + require.NoError(t, err) + assert.Equal(t, "gpt-4", updated.Spec.Config["model"]) + assert.Equal(t, uint64(2), updated.ResourceVersion) +} + +func TestProvider_Update_NotFound(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, err := pc.Update(ctx, "default", &types.Provider{Name: "nonexistent"}) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_Delete(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, "default", &types.Provider{Name: "openai"}) + + err := pc.Delete(ctx, "default", "openai") + require.NoError(t, err) + + _, err = pc.Get(ctx, "default", "openai") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestProvider_Delete_Idempotent(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + err := pc.Delete(ctx, "default", "nonexistent") + require.NoError(t, err) +} + +func TestProvider_Ensure_CreatesIfMissing(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + p := &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + } + + result, err := pc.Ensure(ctx, "default", p) + require.NoError(t, err) + assert.Equal(t, "openai", result.Name) + assert.Equal(t, "gpt-4", result.Spec.Config["model"]) + + // Verify it was stored + got, err := pc.Get(ctx, "default", "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) +} + +func TestProvider_Ensure_UpdatesIfExists(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, "default", &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-3.5"}}, + }) + + result, err := pc.Ensure(ctx, "default", &types.Provider{ + Name: "openai", + Type: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + require.NoError(t, err) + assert.Equal(t, "gpt-4", result.Spec.Config["model"]) +} + +func TestProvider_DeepCopy_OnCreate(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + spec := types.ProviderSpec{ + Credentials: map[string]string{"key": "secret"}, + Config: map[string]string{"model": "gpt-4"}, + CredentialExpiresAt: map[string]time.Time{"key": time.Now()}, + } + p := &types.Provider{ + Name: "openai", + Labels: map[string]string{"env": "test"}, + Spec: spec, + } + + result, err := pc.Create(ctx, "default", p) + require.NoError(t, err) + + // Mutate inputs + p.Labels["env"] = "mutated" + p.Spec.Credentials["key"] = "mutated" + p.Spec.Config["model"] = "mutated" + + got, err := pc.Get(ctx, "default", "openai") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) + assert.Equal(t, "secret", got.Spec.Credentials["key"]) + assert.Equal(t, "gpt-4", got.Spec.Config["model"]) + + // Mutate returned object + result.Labels["env"] = "mutated-return" + got2, err := pc.Get(ctx, "default", "openai") + require.NoError(t, err) + assert.Equal(t, "test", got2.Labels["env"]) +} + +func TestProvider_DeepCopy_OnGet(t *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + _, _ = pc.Create(ctx, "default", &types.Provider{ + Name: "openai", + Spec: types.ProviderSpec{Config: map[string]string{"model": "gpt-4"}}, + }) + + got, err := pc.Get(ctx, "default", "openai") + require.NoError(t, err) + + got.Spec.Config["model"] = "mutated" + + got2, err := pc.Get(ctx, "default", "openai") + require.NoError(t, err) + assert.Equal(t, "gpt-4", got2.Spec.Config["model"]) +} + +// --- T020: Concurrent provider access tests --- + +func TestProvider_ConcurrentCreateGetListDeleteEnsure(_ *testing.T) { + pc := newTestProviderClient() + ctx := context.Background() + + const goroutines = 10 + const opsPerGoroutine = 20 + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsPerGoroutine; j++ { + name := fmt.Sprintf("prov-%d-%d", id, j) + p := &types.Provider{Name: name, Type: "test"} + _, _ = pc.Create(ctx, "default", p) + _, _ = pc.Get(ctx, "default", name) + _, _ = pc.List(ctx, "default") + _, _ = pc.Update(ctx, "default", &types.Provider{Name: name, Type: "updated"}) + _, _ = pc.Ensure(ctx, "default", &types.Provider{Name: name, Type: "ensured"}) + _ = pc.Delete(ctx, "default", name) + } + }(i) + } + wg.Wait() +} diff --git a/sdk/go/openshell/v1/fake/refresh.go b/sdk/go/openshell/v1/fake/refresh.go new file mode 100644 index 0000000000..034f2a33df --- /dev/null +++ b/sdk/go/openshell/v1/fake/refresh.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeRefreshClient implements v1.RefreshInterface. All methods return +// Unimplemented because credential refresh requires a real server. +type fakeRefreshClient struct { + closedFunc func() bool +} + +// newFakeRefreshClient creates a new fakeRefreshClient. +func newFakeRefreshClient(closedFunc func() bool) *fakeRefreshClient { + return &fakeRefreshClient{closedFunc: closedFunc} +} + +// GetStatus returns Unimplemented. +func (c *fakeRefreshClient) GetStatus(_ context.Context, _, _, _ string) ([]*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetStatus is not supported by the fake client"} +} + +// Configure returns Unimplemented. +func (c *fakeRefreshClient) Configure(_ context.Context, _ string, _ *types.RefreshConfig) (*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Configure is not supported by the fake client"} +} + +// Rotate returns Unimplemented. +func (c *fakeRefreshClient) Rotate(_ context.Context, _, _, _ string) (*types.RefreshStatus, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Rotate is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeRefreshClient) Delete(_ context.Context, _, _, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeRefreshClient implements v1.RefreshInterface. +var _ v1.RefreshInterface = (*fakeRefreshClient)(nil) diff --git a/sdk/go/openshell/v1/fake/refresh_test.go b/sdk/go/openshell/v1/fake/refresh_test.go new file mode 100644 index 0000000000..4af951dcd3 --- /dev/null +++ b/sdk/go/openshell/v1/fake/refresh_test.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T028: fakeRefreshClient stub tests --- + +func TestFakeRefresh_GetStatus_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.GetStatus(context.Background(), "default", "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Configure_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Configure(context.Background(), "default", &types.RefreshConfig{ + Provider: "provider-1", + CredentialKey: "cred-1", + }) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Rotate_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Rotate(context.Background(), "default", "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeRefreshClient(func() bool { return false }) + _, err := c.Delete(context.Background(), "default", "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeRefresh_GetStatus_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.GetStatus(context.Background(), "default", "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Configure_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Configure(context.Background(), "default", &types.RefreshConfig{ + Provider: "provider-1", + CredentialKey: "cred-1", + }) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Rotate_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Rotate(context.Background(), "default", "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeRefresh_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeRefreshClient(func() bool { return true }) + _, err := c.Delete(context.Background(), "default", "provider-1", "cred-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/sandbox.go b/sdk/go/openshell/v1/fake/sandbox.go new file mode 100644 index 0000000000..7f53bc876e --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox.go @@ -0,0 +1,600 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// sandboxName extracts the name from a Sandbox pointer for use as the +// objectStore key function. +func sandboxName(sb *types.Sandbox) string { + return sb.Name +} + +// copySandbox returns a deep copy of a Sandbox pointer. All maps, slices, +// and nested pointer fields are duplicated to prevent aliasing. +func copySandbox(sb *types.Sandbox) *types.Sandbox { + if sb == nil { + return nil + } + cp := *sb + cp.Labels = copyStringMap(sb.Labels) + cp.Annotations = copyStringMap(sb.Annotations) + if sb.DeletionTimestamp != nil { + t := *sb.DeletionTimestamp + cp.DeletionTimestamp = &t + } + cp.Spec = copySandboxSpec(sb.Spec) + cp.Status = copySandboxStatus(sb.Status) + return &cp +} + +func copySandboxSpec(s types.SandboxSpec) types.SandboxSpec { + s.Environment = copyStringMap(s.Environment) + s.Providers = copyStringSlice(s.Providers) + if s.Template != nil { + t := copySandboxTemplate(*s.Template) + s.Template = &t + } + if s.GPUCount != nil { + v := *s.GPUCount + s.GPUCount = &v + } + s.Policy = copySandboxPolicy(s.Policy) + return s +} + +// copySandboxPolicy returns a deep copy of a SandboxPolicy pointer. +// All sub-policies, slices, and map entries are duplicated. +func copySandboxPolicy(p *types.SandboxPolicy) *types.SandboxPolicy { + if p == nil { + return nil + } + cp := *p + if p.Filesystem != nil { + fs := *p.Filesystem + fs.ReadOnly = copyStringSlice(p.Filesystem.ReadOnly) + fs.ReadWrite = copyStringSlice(p.Filesystem.ReadWrite) + cp.Filesystem = &fs + } + if p.Landlock != nil { + ll := *p.Landlock + cp.Landlock = &ll + } + if p.Process != nil { + pr := *p.Process + cp.Process = &pr + } + if p.NetworkPolicies != nil { + np := make(map[string]types.NetworkPolicyRule, len(p.NetworkPolicies)) + for k, rule := range p.NetworkPolicies { + r := rule + if rule.Endpoints != nil { + eps := make([]types.PolicyNetworkEndpoint, len(rule.Endpoints)) + for i, ep := range rule.Endpoints { + eps[i] = copyPolicyNetworkEndpoint(ep) + } + r.Endpoints = eps + } + if rule.Binaries != nil { + bins := make([]types.PolicyNetworkBinary, len(rule.Binaries)) + copy(bins, rule.Binaries) + r.Binaries = bins + } + np[k] = r + } + cp.NetworkPolicies = np + } + if p.NetworkMiddlewares != nil { + nm := make(map[string]types.NetworkMiddlewareConfig, len(p.NetworkMiddlewares)) + for k, mw := range p.NetworkMiddlewares { + mw.Config = copyAnyMap(mw.Config) + if mw.Endpoints != nil { + ep := *mw.Endpoints + ep.Include = copyStringSlice(mw.Endpoints.Include) + ep.Exclude = copyStringSlice(mw.Endpoints.Exclude) + mw.Endpoints = &ep + } + nm[k] = mw + } + cp.NetworkMiddlewares = nm + } + return &cp +} + +func copyPolicyNetworkEndpoint(ep types.PolicyNetworkEndpoint) types.PolicyNetworkEndpoint { + if ep.Ports != nil { + ports := make([]uint32, len(ep.Ports)) + copy(ports, ep.Ports) + ep.Ports = ports + } + if ep.Rules != nil { + rules := make([]types.L7Rule, len(ep.Rules)) + for i, r := range ep.Rules { + rules[i] = r + if r.Allow != nil { + a := *r.Allow + a.Query = copyL7QueryMap(r.Allow.Query) + a.Fields = copyStringSlice(r.Allow.Fields) + a.Params = copyL7QueryMap(r.Allow.Params) + rules[i].Allow = &a + } + } + ep.Rules = rules + } + ep.AllowedIPs = copyStringSlice(ep.AllowedIPs) + if ep.DenyRules != nil { + drs := make([]types.L7DenyRule, len(ep.DenyRules)) + for i, dr := range ep.DenyRules { + dr.Query = copyL7QueryMap(dr.Query) + dr.Fields = copyStringSlice(dr.Fields) + dr.Params = copyL7QueryMap(dr.Params) + drs[i] = dr + } + ep.DenyRules = drs + } + if ep.GraphqlPersistedQueries != nil { + gq := make(map[string]types.GraphqlOperation, len(ep.GraphqlPersistedQueries)) + for k, v := range ep.GraphqlPersistedQueries { + v.Fields = copyStringSlice(v.Fields) + gq[k] = v + } + ep.GraphqlPersistedQueries = gq + } + if ep.CredentialBinding != nil { + cb := *ep.CredentialBinding + ep.CredentialBinding = &cb + } + if ep.Mcp != nil { + mcp := *ep.Mcp + mcp.StrictToolNames = copyBoolPtr(ep.Mcp.StrictToolNames) + mcp.AllowAllKnownMcpMethods = copyBoolPtr(ep.Mcp.AllowAllKnownMcpMethods) + ep.Mcp = &mcp + } + return ep +} + +func copyBoolPtr(p *bool) *bool { + if p == nil { + return nil + } + v := *p + return &v +} + +func copyL7QueryMap(m map[string]types.L7QueryMatcher) map[string]types.L7QueryMatcher { + if m == nil { + return nil + } + cp := make(map[string]types.L7QueryMatcher, len(m)) + for k, v := range m { + v.Any = copyStringSlice(v.Any) + cp[k] = v + } + return cp +} + +func copySandboxTemplate(t types.SandboxTemplate) types.SandboxTemplate { + t.Labels = copyStringMap(t.Labels) + t.Annotations = copyStringMap(t.Annotations) + t.Environment = copyStringMap(t.Environment) + if t.UserNamespaces != nil { + v := *t.UserNamespaces + t.UserNamespaces = &v + } + t.Resources = copyAnyMap(t.Resources) + t.DriverConfig = copyAnyMap(t.DriverConfig) + return t +} + +func copyAnyMap(m map[string]any) map[string]any { + if m == nil { + return nil + } + cp := make(map[string]any, len(m)) + for k, v := range m { + cp[k] = copyAnyValue(v) + } + return cp +} + +func copyAnyValue(v any) any { + switch val := v.(type) { + case map[string]any: + return copyAnyMap(val) + case []any: + s := make([]any, len(val)) + for i, elem := range val { + s[i] = copyAnyValue(elem) + } + return s + default: + return v + } +} + +func copySandboxStatus(s types.SandboxStatus) types.SandboxStatus { + if s.Conditions != nil { + conds := make([]types.SandboxCondition, len(s.Conditions)) + copy(conds, s.Conditions) + s.Conditions = conds + } + return s +} + +// copyStringMap returns a shallow copy of a string-to-string map. +func copyStringMap(m map[string]string) map[string]string { + if m == nil { + return nil + } + cp := make(map[string]string, len(m)) + for k, v := range m { + cp[k] = v + } + return cp +} + +// copyStringSlice returns a copy of a string slice. +func copyStringSlice(s []string) []string { + if s == nil { + return nil + } + cp := make([]string, len(s)) + copy(cp, s) + return cp +} + +// fakeSandboxClient implements v1.SandboxInterface backed by an in-memory +// objectStore and watchBroadcaster. +type fakeSandboxClient struct { + store *objectStore[*types.Sandbox] + broadcaster *watchBroadcaster[*types.Sandbox] + closedFunc func() bool +} + +// newFakeSandboxClient creates a new fakeSandboxClient. +func newFakeSandboxClient( + store *objectStore[*types.Sandbox], + broadcaster *watchBroadcaster[*types.Sandbox], + closedFunc func() bool, +) *fakeSandboxClient { + return &fakeSandboxClient{ + store: store, + broadcaster: broadcaster, + closedFunc: closedFunc, + } +} + +// Create creates a new sandbox with Provisioning phase. +func (c *fakeSandboxClient) Create(_ context.Context, workspace, name string, spec *types.SandboxSpec, labels map[string]string, opts ...types.CreateOptions) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + if spec == nil { + spec = &types.SandboxSpec{} + } + + var annotations map[string]string + if len(opts) > 0 { + annotations = copyStringMap(opts[0].Annotations) + } + + sb := &types.Sandbox{ + Name: name, + Workspace: workspace, + CreatedAt: time.Now(), + Labels: copyStringMap(labels), + Annotations: annotations, + ResourceVersion: 1, + Spec: copySandboxSpec(*spec), + Status: types.SandboxStatus{ + SandboxName: name, + Phase: types.SandboxProvisioning, + }, + } + + result, err := c.store.Create(workspace, sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventAdded, + Object: copySandbox(result), + }, name) + + return result, nil +} + +// Get retrieves a sandbox by name. +func (c *fakeSandboxClient) Get(_ context.Context, workspace, name string) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.store.Get(workspace, name) +} + +// List returns all sandboxes. ListOptions are accepted for interface +// compatibility but filtering is not implemented. +func (c *fakeSandboxClient) List(_ context.Context, workspace string, opts ...v1.ListOptions) ([]*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if len(opts) > 0 && opts[0].AllWorkspaces { + return c.store.ListAll(), nil + } + return c.store.List(workspace), nil +} + +// Delete removes a sandbox by name. The operation is idempotent. +func (c *fakeSandboxClient) Delete(_ context.Context, workspace, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + deleted, existed := c.store.DeleteAndGet(workspace, name) + if !existed { + // Not found — idempotent delete + return nil + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventDeleted, + Object: deleted, + }, name) + + return nil +} + +// WaitReady transitions a sandbox to the Ready phase. In the fake +// implementation this happens synchronously — context cancellation is +// checked first to support timeout testing. +func (c *fakeSandboxClient) WaitReady(ctx context.Context, workspace, name string, _ ...v1.WaitOptions) (*types.Sandbox, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + select { + case <-ctx.Done(): + err := ctx.Err() + switch err { + case context.DeadlineExceeded: + return nil, &types.StatusError{Code: types.ErrorDeadlineExceeded, Message: err.Error(), Cause: err} + case context.Canceled: + return nil, &types.StatusError{Code: types.ErrorCancelled, Message: err.Error(), Cause: err} + default: + return nil, &types.StatusError{Code: types.ErrorInternal, Message: err.Error(), Cause: err} + } + default: + } + + sb, err := c.store.Get(workspace, name) + if err != nil { + return nil, err + } + + // If already ready, return immediately + if sb.Status.Phase == types.SandboxReady { + return sb, nil + } + + sb.Status.Phase = types.SandboxReady + sb.ResourceVersion++ + + updated, err := c.store.Update(workspace, sb) + if err != nil { + return nil, fmt.Errorf("updating sandbox phase: %w", err) + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, name) + + return updated, nil +} + +// Watch registers a watcher for sandbox events. If name is non-empty, only +// events for that sandbox are delivered. When StopOnTerminal is set, the +// watcher auto-closes after delivering a terminal phase event (SandboxReady +// or SandboxError). +func (c *fakeSandboxClient) Watch(ctx context.Context, _, name string, opts ...v1.WatchOptions) (types.WatchInterface[*types.Sandbox], error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + inner := c.broadcaster.Watch(name) + if done := ctx.Done(); done != nil { + go func() { + <-done + inner.Stop() + }() + } + + var stopOnTerminal bool + if len(opts) > 0 { + stopOnTerminal = opts[0].StopOnTerminal + } + + if !stopOnTerminal { + return inner, nil + } + + // Wrap with a filtering watcher that auto-stops after terminal events. + out := make(chan types.Event[*types.Sandbox], watchChannelBuffer) + tw := &terminalWatcher{ + ch: out, + inner: inner, + stopCh: make(chan struct{}), + } + go func() { + defer close(out) + for ev := range inner.ResultChan() { + select { + case out <- ev: + case <-ctx.Done(): + inner.Stop() + return + case <-tw.stopCh: + return + } + if ev.Object != nil && + (ev.Object.Status.Phase == types.SandboxReady || ev.Object.Status.Phase == types.SandboxError) { + inner.Stop() + return + } + } + }() + return tw, nil +} + +// terminalWatcher wraps an inner watcher and exposes its own output channel. +type terminalWatcher struct { + ch chan types.Event[*types.Sandbox] + inner types.WatchInterface[*types.Sandbox] + once sync.Once + stopCh chan struct{} +} + +func (w *terminalWatcher) ResultChan() <-chan types.Event[*types.Sandbox] { + return w.ch +} + +func (w *terminalWatcher) Stop() { + w.once.Do(func() { + close(w.stopCh) + w.inner.Stop() + }) +} + +// AttachProvider adds a provider name to the sandbox's Spec.Providers list. +// If the provider is already attached, Attached is false (idempotent). +// The sandbox's ResourceVersion is incremented and a MODIFIED event is +// broadcast. +func (c *fakeSandboxClient) AttachProvider(_ context.Context, workspace, sandboxName, providerName string, _ uint64) (*types.AttachProviderResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(workspace, sandboxName) + if err != nil { + return nil, err + } + + // Check if already attached + for _, p := range sb.Spec.Providers { + if p == providerName { + return &types.AttachProviderResult{ + Sandbox: sb, + Attached: false, + }, nil + } + } + + sb.Spec.Providers = append(sb.Spec.Providers, providerName) + sb.ResourceVersion++ + + updated, err := c.store.Update(workspace, sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, sandboxName) + + return &types.AttachProviderResult{ + Sandbox: updated, + Attached: true, + }, nil +} + +// DetachProvider removes a provider name from the sandbox's Spec.Providers +// list. If the provider is not attached, Detached is false (idempotent). +// The sandbox's ResourceVersion is incremented and a MODIFIED event is +// broadcast when a provider is actually removed. +func (c *fakeSandboxClient) DetachProvider(_ context.Context, workspace, sandboxName, providerName string, _ uint64) (*types.DetachProviderResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(workspace, sandboxName) + if err != nil { + return nil, err + } + + // Find and remove the provider + found := false + providers := make([]string, 0, len(sb.Spec.Providers)) + for _, p := range sb.Spec.Providers { + if p == providerName { + found = true + continue + } + providers = append(providers, p) + } + + if !found { + return &types.DetachProviderResult{ + Sandbox: sb, + Detached: false, + }, nil + } + + sb.Spec.Providers = providers + sb.ResourceVersion++ + + updated, err := c.store.Update(workspace, sb) + if err != nil { + return nil, err + } + + c.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, sandboxName) + + return &types.DetachProviderResult{ + Sandbox: updated, + Detached: true, + }, nil +} + +// GetLogs returns Unimplemented — fake log retrieval is not yet supported. +func (c *fakeSandboxClient) GetLogs(_ context.Context, _, _ string, _ ...v1.LogOption) (*types.LogResult, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "GetLogs not implemented in fake client"} +} + +// ListProviders returns stub Provider objects for each provider name +// attached to the sandbox. The returned providers contain only the Name +// field, since the fake client does not maintain a full provider registry +// per sandbox. +func (c *fakeSandboxClient) ListProviders(_ context.Context, workspace, sandboxName string) ([]*types.Provider, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + + sb, err := c.store.Get(workspace, sandboxName) + if err != nil { + return nil, err + } + + result := make([]*types.Provider, len(sb.Spec.Providers)) + for i, name := range sb.Spec.Providers { + result[i] = &types.Provider{Name: name} + } + return result, nil +} diff --git a/sdk/go/openshell/v1/fake/sandbox_test.go b/sdk/go/openshell/v1/fake/sandbox_test.go new file mode 100644 index 0000000000..b675621891 --- /dev/null +++ b/sdk/go/openshell/v1/fake/sandbox_test.go @@ -0,0 +1,932 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// helper to build a minimal fake sandbox client for testing. +func newTestSandboxClient() *fakeSandboxClient { + store := newobjectStore(sandboxName, copySandbox) + broadcaster := newWatchBroadcaster[*types.Sandbox]() + return newFakeSandboxClient(store, broadcaster, func() bool { return false }) +} + +// --- T008: Sandbox CRUD tests --- + +func TestSandbox_Create(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{LogLevel: "debug"}, map[string]string{"env": "test"}) + require.NoError(t, err) + assert.Equal(t, "test-sb", sb.Name) + assert.Equal(t, "debug", sb.Spec.LogLevel) + assert.Equal(t, "test", sb.Labels["env"]) + assert.Equal(t, types.SandboxProvisioning, sb.Status.Phase) + assert.NotZero(t, sb.CreatedAt) + assert.Equal(t, uint64(1), sb.ResourceVersion) +} + +func TestSandbox_Create_AlreadyExists(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + _, err = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestSandbox_Create_WithAnnotations(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "annotated", &types.SandboxSpec{}, nil, + types.CreateOptions{Annotations: map[string]string{"source": "cli", "user": "admin"}}) + require.NoError(t, err) + assert.Equal(t, "cli", sb.Annotations["source"]) + assert.Equal(t, "admin", sb.Annotations["user"]) + + got, err := sc.Get(ctx, "default", "annotated") + require.NoError(t, err) + assert.Equal(t, "cli", got.Annotations["source"]) +} + +func TestSandbox_Create_WithAnnotationsDeepCopy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + input := map[string]string{"key": "original"} + sb, err := sc.Create(ctx, "default", "dc-test", &types.SandboxSpec{}, nil, + types.CreateOptions{Annotations: input}) + require.NoError(t, err) + + input["key"] = "MUTATED" + assert.Equal(t, "original", sb.Annotations["key"], "annotations must be deep copied") +} + +func TestSandbox_Create_NoAnnotations(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "no-ann", &types.SandboxSpec{}, nil) + require.NoError(t, err) + assert.Nil(t, sb.Annotations) +} + +func TestCopyAnyMap(t *testing.T) { + t.Run("nil", func(t *testing.T) { + assert.Nil(t, copyAnyMap(nil)) + }) + + t.Run("flat", func(t *testing.T) { + original := map[string]any{"cpu": "2", "memory": "4Gi"} + copied := copyAnyMap(original) + assert.Equal(t, original, copied) + + original["cpu"] = "MUTATED" + assert.Equal(t, "2", copied["cpu"]) + }) + + t.Run("nested map", func(t *testing.T) { + original := map[string]any{ + "limits": map[string]any{"cpu": "4", "memory": "8Gi"}, + } + copied := copyAnyMap(original) + + nested := original["limits"].(map[string]any) + nested["cpu"] = "MUTATED" + + copiedNested := copied["limits"].(map[string]any) + assert.Equal(t, "4", copiedNested["cpu"]) + }) + + t.Run("nested slice", func(t *testing.T) { + original := map[string]any{ + "ports": []any{float64(80), float64(443)}, + } + copied := copyAnyMap(original) + + original["ports"].([]any)[0] = float64(9999) + assert.Equal(t, float64(80), copied["ports"].([]any)[0]) + }) + + t.Run("scalar types", func(t *testing.T) { + original := map[string]any{ + "str": "hello", "num": float64(42), "flag": true, "null": nil, + } + copied := copyAnyMap(original) + assert.Equal(t, original, copied) + }) +} + +func TestCopySandboxTemplate_ResourcesDeepCopy(t *testing.T) { + tmpl := types.SandboxTemplate{ + Image: "img:v1", + Resources: map[string]any{"cpu": "2", "nested": map[string]any{"key": "val"}}, + DriverConfig: map[string]any{"runtime": "kata"}, + } + + copied := copySandboxTemplate(tmpl) + + tmpl.Resources["cpu"] = "MUTATED" + assert.Equal(t, "2", copied.Resources["cpu"]) + + tmpl.DriverConfig["runtime"] = "MUTATED" + assert.Equal(t, "kata", copied.DriverConfig["runtime"]) + + nested := tmpl.Resources["nested"].(map[string]any) + nested["key"] = "MUTATED" + copiedNested := copied.Resources["nested"].(map[string]any) + assert.Equal(t, "val", copiedNested["key"]) +} + +func TestSandbox_Create_NilSpec(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", nil, nil) + require.NoError(t, err) + assert.Equal(t, "test-sb", sb.Name) +} + +func TestSandbox_Get(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + + got, err := sc.Get(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Equal(t, "test-sb", got.Name) + assert.Equal(t, "info", got.Spec.LogLevel) +} + +func TestSandbox_Get_NotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Get(ctx, "default", "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_List_Empty(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + list, err := sc.List(ctx, "default") + require.NoError(t, err) + assert.Empty(t, list) +} + +func TestSandbox_List(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "default", "sb-1", &types.SandboxSpec{}, nil) + _, _ = sc.Create(ctx, "default", "sb-2", &types.SandboxSpec{}, nil) + + list, err := sc.List(ctx, "default") + require.NoError(t, err) + assert.Len(t, list, 2) +} + +func TestSandbox_Delete(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + + err := sc.Delete(ctx, "default", "test-sb") + require.NoError(t, err) + + _, err = sc.Get(ctx, "default", "test-sb") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_Delete_Idempotent(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + // Delete non-existent sandbox should not error + err := sc.Delete(ctx, "default", "nonexistent") + require.NoError(t, err) +} + +func TestSandbox_DeepCopy_OnCreate(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + labels := map[string]string{"env": "test"} + spec := &types.SandboxSpec{ + LogLevel: "debug", + Environment: map[string]string{"KEY": "value"}, + } + + sb, err := sc.Create(ctx, "default", "test-sb", spec, labels) + require.NoError(t, err) + + // Mutating inputs should not affect stored object + labels["env"] = "mutated" + spec.LogLevel = "mutated" + spec.Environment["KEY"] = "mutated" + + got, err := sc.Get(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) + assert.Equal(t, "debug", got.Spec.LogLevel) + assert.Equal(t, "value", got.Spec.Environment["KEY"]) + + // Mutating returned object should not affect stored object + sb.Labels["env"] = "mutated-return" + got2, err := sc.Get(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Equal(t, "test", got2.Labels["env"]) +} + +func TestSandbox_DeepCopy_OnGet(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{ + Environment: map[string]string{"KEY": "value"}, + }, nil) + + got, err := sc.Get(ctx, "default", "test-sb") + require.NoError(t, err) + + got.Spec.Environment["KEY"] = "mutated" + + got2, err := sc.Get(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Equal(t, "value", got2.Spec.Environment["KEY"]) +} + +// --- T009: WaitReady tests --- + +func TestSandbox_WaitReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + sb, err := sc.WaitReady(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) + + // Verify the store is also updated + got, err := sc.Get(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, got.Status.Phase) +} + +func TestSandbox_WaitReady_NotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.WaitReady(ctx, "default", "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_WaitReady_ContextCancellation(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + _, err = sc.WaitReady(ctx, "default", "test-sb") + require.Error(t, err) + // Should return a context error, not a status error + assert.ErrorIs(t, err, context.Canceled) +} + +func TestSandbox_WaitReady_ContextDeadlineExceeded(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + + _, err = sc.WaitReady(ctx, "default", "test-sb") + require.Error(t, err) + assert.True(t, types.IsDeadlineExceeded(err), "WaitReady must wrap context.DeadlineExceeded in StatusError") +} + +func TestSandbox_WaitReady_AlreadyReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + + // Make it ready + _, err := sc.WaitReady(ctx, "default", "test-sb") + require.NoError(t, err) + + // WaitReady on an already-ready sandbox should return immediately + sb, err := sc.WaitReady(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Equal(t, types.SandboxReady, sb.Status.Phase) +} + +func TestSandbox_WaitReady_IncrementsResourceVersion(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + created, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + initialVersion := created.ResourceVersion + + ready, err := sc.WaitReady(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Greater(t, ready.ResourceVersion, initialVersion) +} + +func TestSandbox_WaitReady_ContextTimeout(t *testing.T) { + sc := newTestSandboxClient() + + _, err := sc.Create(context.Background(), "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + defer cancel() + + // Override the sandbox phase to Error so WaitReady doesn't auto-transition + // Actually, with our simple fake, WaitReady transitions immediately unless context is done. + // So just test the context-cancelled path: + cancel() + _, err = sc.WaitReady(ctx, "default", "test-sb") + require.Error(t, err) +} + +// --- T011: Watch tests --- + +func TestSandbox_Watch_AddedOnCreate(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + assert.Equal(t, "info", ev.Object.Spec.LogLevel) + case <-time.After(time.Second): + t.Fatal("timed out waiting for ADDED event") + } +} + +func TestSandbox_Watch_DeletedOnDelete(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + + w, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + defer w.Stop() + + err = sc.Delete(ctx, "default", "test-sb") + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventDeleted, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for DELETED event") + } +} + +func TestSandbox_Watch_ModifiedOnWaitReady(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + + w, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.WaitReady(ctx, "default", "test-sb") + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventModified, ev.Type) + assert.Equal(t, types.SandboxReady, ev.Object.Status.Phase) + case <-time.After(time.Second): + t.Fatal("timed out waiting for MODIFIED event") + } +} + +func TestSandbox_Watch_NameFiltering(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + // Watch only "alpha" + w, err := sc.Watch(ctx, "default", "alpha") + require.NoError(t, err) + defer w.Stop() + + // Create "beta" — should not be received + _, _ = sc.Create(ctx, "default", "beta", &types.SandboxSpec{}, nil) + + // Create "alpha" — should be received + _, _ = sc.Create(ctx, "default", "alpha", &types.SandboxSpec{}, nil) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "alpha", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for filtered event") + } +} + +func TestSandbox_Watch_MultipleWatchers(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w1, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + defer w1.Stop() + + w2, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + defer w2.Stop() + + _, _ = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + + for _, w := range []types.WatchInterface[*types.Sandbox]{w1, w2} { + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventAdded, ev.Type) + assert.Equal(t, "test-sb", ev.Object.Name) + case <-time.After(time.Second): + t.Fatal("timed out waiting for event on watcher") + } + } +} + +func TestSandbox_Watch_StopClosesChannel(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + w, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + + w.Stop() + + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should be closed after Stop") +} + +func TestSandbox_Watch_DeletedEventContainsFullObject(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, _ = sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{LogLevel: "debug"}, map[string]string{"env": "test"}) + + w, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + defer w.Stop() + + _ = sc.Delete(ctx, "default", "test-sb") + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventDeleted, ev.Type) + // Verify the DELETED event contains the full last-known object + assert.Equal(t, "debug", ev.Object.Spec.LogLevel) + assert.Equal(t, "test", ev.Object.Labels["env"]) + case <-time.After(time.Second): + t.Fatal("timed out waiting for DELETED event") + } +} + +// --- T019: Concurrent sandbox access tests --- + +func TestSandbox_ConcurrentCreateGetDeleteWatch(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + const goroutines = 10 + const opsPerGoroutine = 20 + + // Start a watcher to exercise broadcast under concurrency + w, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + defer w.Stop() + + // Drain watcher events in a background goroutine + done := make(chan struct{}) + go func() { + defer close(done) + for range w.ResultChan() { //nolint:revive // intentionally draining channel + } + }() + + var wg sync.WaitGroup + for i := 0; i < goroutines; i++ { + wg.Add(1) + go func(id int) { + defer wg.Done() + for j := 0; j < opsPerGoroutine; j++ { + name := fmt.Sprintf("sb-%d-%d", id, j) + _, _ = sc.Create(ctx, "default", name, &types.SandboxSpec{LogLevel: "info"}, nil) + _, _ = sc.Get(ctx, "default", name) + _, _ = sc.List(ctx, "default") + _, _ = sc.WaitReady(ctx, "default", name) + _ = sc.Delete(ctx, "default", name) + } + }(i) + } + wg.Wait() + + // Stop watcher and wait for drain goroutine + w.Stop() + <-done +} + +// --- T026: AttachProvider / DetachProvider / ListProviders tests --- + +func TestSandbox_AttachProvider(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "default", "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.True(t, result.Attached) + assert.Equal(t, "test-sb", result.Sandbox.Name) + assert.Contains(t, result.Sandbox.Spec.Providers, "openai") +} + +func TestSandbox_AttachProvider_AlreadyAttached(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "default", "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.True(t, result.Attached) + + // Attach again — should return Attached=false (idempotent, already attached) + result2, err := sc.AttachProvider(ctx, "default", "test-sb", "openai", result.Sandbox.ResourceVersion) + require.NoError(t, err) + assert.False(t, result2.Attached) +} + +func TestSandbox_AttachProvider_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.AttachProvider(ctx, "default", "nonexistent", "openai", 0) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_DetachProvider(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.AttachProvider(ctx, "default", "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + detach, err := sc.DetachProvider(ctx, "default", "test-sb", "openai", result.Sandbox.ResourceVersion) + require.NoError(t, err) + assert.True(t, detach.Detached) + assert.NotContains(t, detach.Sandbox.Spec.Providers, "openai") +} + +func TestSandbox_DetachProvider_NotAttached(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + result, err := sc.DetachProvider(ctx, "default", "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + assert.False(t, result.Detached) +} + +func TestSandbox_DetachProvider_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.DetachProvider(ctx, "default", "nonexistent", "openai", 0) + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_ListProviders(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + // No providers yet + providers, err := sc.ListProviders(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Empty(t, providers) + + // Attach two providers + result, err := sc.AttachProvider(ctx, "default", "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + _, err = sc.AttachProvider(ctx, "default", "test-sb", "anthropic", result.Sandbox.ResourceVersion) + require.NoError(t, err) + + providers, err = sc.ListProviders(ctx, "default", "test-sb") + require.NoError(t, err) + assert.Len(t, providers, 2) + + names := make([]string, len(providers)) + for i, p := range providers { + names[i] = p.Name + } + assert.Contains(t, names, "openai") + assert.Contains(t, names, "anthropic") +} + +func TestSandbox_ListProviders_SandboxNotFound(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.ListProviders(ctx, "default", "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestSandbox_AttachProvider_BroadcastsModified(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "default", "") + require.NoError(t, err) + defer w.Stop() + + _, err = sc.AttachProvider(ctx, "default", "test-sb", "openai", sb.ResourceVersion) + require.NoError(t, err) + + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.EventModified, ev.Type) + assert.Contains(t, ev.Object.Spec.Providers, "openai") + case <-time.After(time.Second): + t.Fatal("timed out waiting for MODIFIED event from AttachProvider") + } +} + +// --- T033: StopOnTerminal tests for fake Watch --- + +func TestSandbox_Watch_StopOnTerminal_Ready(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "default", "test-sb", v1.WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + + // Transition to Ready — this broadcasts a MODIFIED event with SandboxReady phase + _, err = sc.WaitReady(ctx, "default", "test-sb") + require.NoError(t, err) + + // Should receive the Ready event + var gotReady bool + for ev := range w.ResultChan() { + if ev.Object != nil && ev.Object.Status.Phase == types.SandboxReady { + gotReady = true + } + } + // Channel should be closed after the terminal event + assert.True(t, gotReady, "expected to receive a Ready event before channel closed") +} + +func TestSandbox_Watch_StopOnTerminal_Error(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + sb, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + w, err := sc.Watch(ctx, "default", "test-sb", v1.WatchOptions{StopOnTerminal: true}) + require.NoError(t, err) + + // Manually transition to Error phase via store update + broadcast + sb.Status.Phase = types.SandboxError + sb.ResourceVersion++ + updated, err := sc.store.Update("default", sb) + require.NoError(t, err) + sc.broadcaster.Broadcast(types.Event[*types.Sandbox]{ + Type: types.EventModified, + Object: copySandbox(updated), + }, "test-sb") + + // Should receive the Error event and then the channel closes + var gotError bool + for ev := range w.ResultChan() { + if ev.Object != nil && ev.Object.Status.Phase == types.SandboxError { + gotError = true + } + } + assert.True(t, gotError, "expected to receive an Error event before channel closed") +} + +func TestSandbox_Watch_StopOnTerminal_False_DoesNotClose(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + _, err := sc.Create(ctx, "default", "test-sb", &types.SandboxSpec{}, nil) + require.NoError(t, err) + + // Watch WITHOUT StopOnTerminal + w, err := sc.Watch(ctx, "default", "test-sb") + require.NoError(t, err) + defer w.Stop() + + // Transition to Ready + _, err = sc.WaitReady(ctx, "default", "test-sb") + require.NoError(t, err) + + // Receive the Ready event + select { + case ev := <-w.ResultChan(): + assert.Equal(t, types.SandboxReady, ev.Object.Status.Phase) + case <-time.After(time.Second): + t.Fatal("timed out waiting for Ready event") + } + + // Channel should still be open — verify by checking no close + select { + case _, ok := <-w.ResultChan(): + if !ok { + t.Fatal("channel closed unexpectedly when StopOnTerminal was not set") + } + // Got another event, that's fine + case <-time.After(100 * time.Millisecond): + // No event and not closed — correct behavior + } +} + +// --- T016: Sandbox Create with Policy --- + +func TestFakeSandboxCreateWithPolicy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + spec := &types.SandboxSpec{ + LogLevel: "debug", + Policy: &types.SandboxPolicy{ + Version: 3, + Filesystem: &types.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp"}, + }, + Landlock: &types.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &types.ProcessPolicy{ + RunAsUser: "sandbox", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]types.NetworkPolicyRule{ + "web": { + Name: "web", + Endpoints: []types.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + }, + }, + } + + created, err := sc.Create(ctx, "default", "policy-sb", spec, nil) + require.NoError(t, err) + + // Verify created sandbox has policy + require.NotNil(t, created.Spec.Policy) + assert.Equal(t, uint32(3), created.Spec.Policy.Version) + + // Get it back and verify all fields + got, err := sc.Get(ctx, "default", "policy-sb") + require.NoError(t, err) + require.NotNil(t, got.Spec.Policy) + + p := got.Spec.Policy + assert.Equal(t, uint32(3), p.Version) + + require.NotNil(t, p.Filesystem) + assert.True(t, p.Filesystem.IncludeWorkdir) + assert.Equal(t, []string{"/etc", "/usr/share"}, p.Filesystem.ReadOnly) + assert.Equal(t, []string{"/tmp"}, p.Filesystem.ReadWrite) + + require.NotNil(t, p.Landlock) + assert.Equal(t, "best_effort", p.Landlock.Compatibility) + + require.NotNil(t, p.Process) + assert.Equal(t, "sandbox", p.Process.RunAsUser) + assert.Equal(t, "sandbox-group", p.Process.RunAsGroup) + + require.Len(t, p.NetworkPolicies, 1) + webRule, ok := p.NetworkPolicies["web"] + require.True(t, ok) + assert.Equal(t, "web", webRule.Name) + require.Len(t, webRule.Endpoints, 1) + assert.Equal(t, "api.example.com", webRule.Endpoints[0].Host) + assert.Equal(t, uint32(443), webRule.Endpoints[0].Port) + + // Deep-copy isolation: mutate input spec, verify stored copy unchanged + spec.Policy.Version = 99 + spec.Policy.Filesystem.ReadOnly[0] = "mutated" + spec.Policy.NetworkPolicies["web"] = types.NetworkPolicyRule{Name: "mutated"} + + got2, err := sc.Get(ctx, "default", "policy-sb") + require.NoError(t, err) + assert.Equal(t, uint32(3), got2.Spec.Policy.Version) + assert.Equal(t, "/etc", got2.Spec.Policy.Filesystem.ReadOnly[0]) + assert.Equal(t, "web", got2.Spec.Policy.NetworkPolicies["web"].Name) + + // Deep-copy isolation: mutate returned object, verify store unchanged + got.Spec.Policy.Filesystem.ReadWrite[0] = "mutated" + got3, err := sc.Get(ctx, "default", "policy-sb") + require.NoError(t, err) + assert.Equal(t, "/tmp", got3.Spec.Policy.Filesystem.ReadWrite[0]) +} + +func TestFakeSandboxCreateWithNilPolicy(t *testing.T) { + sc := newTestSandboxClient() + ctx := context.Background() + + created, err := sc.Create(ctx, "default", "no-policy-sb", &types.SandboxSpec{LogLevel: "info"}, nil) + require.NoError(t, err) + assert.Nil(t, created.Spec.Policy) + + got, err := sc.Get(ctx, "default", "no-policy-sb") + require.NoError(t, err) + assert.Nil(t, got.Spec.Policy) +} + +// --- T032: GetLogs stub tests --- + +func TestSandbox_GetLogs_ReturnsUnimplemented(t *testing.T) { + sc := newTestSandboxClient() + _, err := sc.GetLogs(context.Background(), "default", "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestSandbox_GetLogs_ClosedReturnsUnavailable(t *testing.T) { + store := newobjectStore(sandboxName, copySandbox) + broadcaster := newWatchBroadcaster[*types.Sandbox]() + sc := newFakeSandboxClient(store, broadcaster, func() bool { return true }) + _, err := sc.GetLogs(context.Background(), "default", "sb-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/service.go b/sdk/go/openshell/v1/fake/service.go new file mode 100644 index 0000000000..8b0c4e1482 --- /dev/null +++ b/sdk/go/openshell/v1/fake/service.go @@ -0,0 +1,57 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeServiceClient implements v1.ServiceInterface. All methods return +// Unimplemented because service exposure requires a real sandbox runtime. +type fakeServiceClient struct { + closedFunc func() bool +} + +// newFakeServiceClient creates a new fakeServiceClient. +func newFakeServiceClient(closedFunc func() bool) *fakeServiceClient { + return &fakeServiceClient{closedFunc: closedFunc} +} + +// Expose returns Unimplemented. +func (c *fakeServiceClient) Expose(_ context.Context, _, _, _ string, _ uint32, _ bool) (*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Expose is not supported by the fake client"} +} + +// Get returns Unimplemented. +func (c *fakeServiceClient) Get(_ context.Context, _, _, _ string) (*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Get is not supported by the fake client"} +} + +// List returns Unimplemented. +func (c *fakeServiceClient) List(_ context.Context, _, _ string, _ ...v1.ListOptions) ([]*types.ServiceEndpoint, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "List is not supported by the fake client"} +} + +// Delete returns Unimplemented. +func (c *fakeServiceClient) Delete(_ context.Context, _, _, _ string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return &types.StatusError{Code: types.ErrorUnimplemented, Message: "Delete is not supported by the fake client"} +} + +// Compile-time check that fakeServiceClient implements v1.ServiceInterface. +var _ v1.ServiceInterface = (*fakeServiceClient)(nil) diff --git a/sdk/go/openshell/v1/fake/service_test.go b/sdk/go/openshell/v1/fake/service_test.go new file mode 100644 index 0000000000..3e3a6b7ca8 --- /dev/null +++ b/sdk/go/openshell/v1/fake/service_test.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T026: fakeServiceClient stub tests --- + +func TestFakeService_Expose_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.Expose(context.Background(), "default", "sb1", "svc1", 8080, false) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Get_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.Get(context.Background(), "default", "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_List_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + _, err := c.List(context.Background(), "default", "sb1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Delete_ReturnsUnimplemented(t *testing.T) { + c := newFakeServiceClient(func() bool { return false }) + err := c.Delete(context.Background(), "default", "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeService_Expose_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.Expose(context.Background(), "default", "sb1", "svc1", 8080, false) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_Get_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.Get(context.Background(), "default", "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_List_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + _, err := c.List(context.Background(), "default", "sb1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeService_Delete_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeServiceClient(func() bool { return true }) + err := c.Delete(context.Background(), "default", "sb1", "svc1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/ssh.go b/sdk/go/openshell/v1/fake/ssh.go new file mode 100644 index 0000000000..8bfa29c294 --- /dev/null +++ b/sdk/go/openshell/v1/fake/ssh.go @@ -0,0 +1,58 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "io" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeSSHClient implements v1.SSHInterface. All methods return +// Unimplemented because SSH session management requires a real gateway. +type fakeSSHClient struct { + closedFunc func() bool +} + +// newFakeSSHClient creates a new fakeSSHClient. +func newFakeSSHClient(closedFunc func() bool) *fakeSSHClient { + return &fakeSSHClient{closedFunc: closedFunc} +} + +// CreateSession returns Unimplemented. +func (c *fakeSSHClient) CreateSession(_ context.Context, _, _ string) (*types.SSHSession, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "CreateSession is not supported by the fake client"} +} + +// RevokeSession returns Unimplemented. +func (c *fakeSSHClient) RevokeSession(_ context.Context, _, _ string) (bool, error) { + if c.closedFunc() { + return false, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return false, &types.StatusError{Code: types.ErrorUnimplemented, Message: "RevokeSession is not supported by the fake client"} +} + +// Tunnel returns Unimplemented. Ports outside 1-65535 and empty sandbox names +// are rejected with InvalidArgument to match the real client's behavior. +func (c *fakeSSHClient) Tunnel(_ context.Context, _, sandboxName string, port uint32, _ ...v1.TunnelOption) (io.ReadWriteCloser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if port == 0 || port > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Tunnel is not supported by the fake client"} +} + +// Compile-time check that fakeSSHClient implements v1.SSHInterface. +var _ v1.SSHInterface = (*fakeSSHClient)(nil) diff --git a/sdk/go/openshell/v1/fake/ssh_test.go b/sdk/go/openshell/v1/fake/ssh_test.go new file mode 100644 index 0000000000..c0859584ef --- /dev/null +++ b/sdk/go/openshell/v1/fake/ssh_test.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T018: fakeSSHClient stub tests --- + +func TestFakeSSH_CreateSession_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.CreateSession(context.Background(), "default", "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_RevokeSession_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.RevokeSession(context.Background(), "default", "tok-abc") + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_CreateSession_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.CreateSession(context.Background(), "default", "sandbox-1") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeSSH_RevokeSession_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.RevokeSession(context.Background(), "default", "tok-abc") + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +// --- T014: Fake Tunnel tests --- + +func TestFakeSSH_Tunnel_ReturnsUnimplemented(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "default", "my-sandbox", 22) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_Tunnel_WithTunnelOption(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "default", "my-sandbox", 22, v1.WithTunnelServiceID("audit-svc")) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeSSH_Tunnel_InvalidPort(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + + _, err := c.Tunnel(context.Background(), "default", "my-sandbox", 0) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + + _, err = c.Tunnel(context.Background(), "default", "my-sandbox", 65536) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeSSH_Tunnel_EmptySandboxName(t *testing.T) { + c := newFakeSSHClient(func() bool { return false }) + _, err := c.Tunnel(context.Background(), "default", "", 22) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeSSH_Tunnel_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeSSHClient(func() bool { return true }) + _, err := c.Tunnel(context.Background(), "default", "my-sandbox", 22) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} diff --git a/sdk/go/openshell/v1/fake/store.go b/sdk/go/openshell/v1/fake/store.go new file mode 100644 index 0000000000..3d97b240e0 --- /dev/null +++ b/sdk/go/openshell/v1/fake/store.go @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "fmt" + "strings" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +func compositeKey(workspace, name string) string { + return workspace + "/" + name +} + +// objectStore is a generic, thread-safe, in-memory store for named objects. +// It deep-copies objects at all boundaries (insert and retrieval) to prevent +// callers from mutating internal state. Items are keyed by composite +// "workspace/name" keys for workspace isolation. +type objectStore[T any] struct { + mu sync.RWMutex + items map[string]T + nameFunc func(T) string + copyFunc func(T) T +} + +// newobjectStore creates a new objectStore with the given name-extraction +// and deep-copy functions. +func newobjectStore[T any](nameFunc func(T) string, copyFunc func(T) T) *objectStore[T] { + return &objectStore[T]{ + items: make(map[string]T), + nameFunc: nameFunc, + copyFunc: copyFunc, + } +} + +// Create adds a new object to the store scoped to the given workspace. +// Returns AlreadyExists if an object with the same workspace/name already +// exists. The object is deep-copied on insert and a deep copy is returned. +func (s *objectStore[T]) Create(workspace string, obj T) (T, error) { + name := s.nameFunc(obj) + key := compositeKey(workspace, name) + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.items[key]; exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorAlreadyExists, + Message: fmt.Sprintf("%s already exists", name), + } + } + + stored := s.copyFunc(obj) + s.items[key] = stored + return s.copyFunc(stored), nil +} + +// Get retrieves an object by workspace and name. Returns NotFound if the +// object does not exist. The returned object is a deep copy. +func (s *objectStore[T]) Get(workspace, name string) (T, error) { + key := compositeKey(workspace, name) + s.mu.RLock() + defer s.mu.RUnlock() + + obj, exists := s.items[key] + if !exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorNotFound, + Message: fmt.Sprintf("%s not found", name), + } + } + return s.copyFunc(obj), nil +} + +// List returns deep copies of all objects in the given workspace. +func (s *objectStore[T]) List(workspace string) []T { + prefix := workspace + "/" + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]T, 0) + for key, obj := range s.items { + if strings.HasPrefix(key, prefix) { + result = append(result, s.copyFunc(obj)) + } + } + return result +} + +// ListAll returns deep copies of all objects across all workspaces. +func (s *objectStore[T]) ListAll() []T { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]T, 0, len(s.items)) + for _, obj := range s.items { + result = append(result, s.copyFunc(obj)) + } + return result +} + +// Update replaces an existing object in the store within the given workspace. +// Returns NotFound if the object does not exist. The object is deep-copied +// on insert and a deep copy is returned. +func (s *objectStore[T]) Update(workspace string, obj T) (T, error) { + name := s.nameFunc(obj) + key := compositeKey(workspace, name) + s.mu.Lock() + defer s.mu.Unlock() + + if _, exists := s.items[key]; !exists { + var zero T + return zero, &types.StatusError{ + Code: types.ErrorNotFound, + Message: fmt.Sprintf("%s not found", name), + } + } + + stored := s.copyFunc(obj) + s.items[key] = stored + return s.copyFunc(stored), nil +} + +// Delete removes an object from the store by workspace and name. The +// operation is idempotent. +func (s *objectStore[T]) Delete(workspace, name string) { + key := compositeKey(workspace, name) + s.mu.Lock() + defer s.mu.Unlock() + delete(s.items, key) +} + +func (s *objectStore[T]) DeleteWorkspace(workspace string) { + prefix := workspace + "/" + s.mu.Lock() + defer s.mu.Unlock() + for key := range s.items { + if strings.HasPrefix(key, prefix) { + delete(s.items, key) + } + } +} + +// DeleteAndGet atomically removes an object from the store and returns a +// deep copy of the removed object. Returns the zero value and false if the +// object did not exist. +func (s *objectStore[T]) DeleteAndGet(workspace, name string) (T, bool) { + key := compositeKey(workspace, name) + s.mu.Lock() + defer s.mu.Unlock() + + obj, exists := s.items[key] + if !exists { + var zero T + return zero, false + } + delete(s.items, key) + return s.copyFunc(obj), true +} + +// Insert directly places an object into the store without checking for +// duplicates. This is intended for pre-seeding test fixtures. The object +// is deep-copied on insert. +func (s *objectStore[T]) Insert(workspace string, obj T) { + name := s.nameFunc(obj) + key := compositeKey(workspace, name) + s.mu.Lock() + defer s.mu.Unlock() + s.items[key] = s.copyFunc(obj) +} diff --git a/sdk/go/openshell/v1/fake/store_test.go b/sdk/go/openshell/v1/fake/store_test.go new file mode 100644 index 0000000000..fff5ad0079 --- /dev/null +++ b/sdk/go/openshell/v1/fake/store_test.go @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "sort" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// testItem is a simple struct used for objectStore tests. +type testItem struct { + Name string + Value string + Tags map[string]string +} + +func testItemName(t *testItem) string { return t.Name } + +func copyTestItem(t *testItem) *testItem { + if t == nil { + return nil + } + c := *t + if t.Tags != nil { + c.Tags = make(map[string]string, len(t.Tags)) + for k, v := range t.Tags { + c.Tags[k] = v + } + } + return &c +} + +func newTestStore() *objectStore[*testItem] { + return newobjectStore(testItemName, copyTestItem) +} + +const testWorkspace = "default" + +func TestObjectStore_Create(t *testing.T) { + s := newTestStore() + + item := &testItem{Name: "alpha", Value: "v1"} + created, err := s.Create(testWorkspace, item) + require.NoError(t, err) + assert.Equal(t, "alpha", created.Name) + assert.Equal(t, "v1", created.Value) +} + +func TestObjectStore_Create_AlreadyExists(t *testing.T) { + s := newTestStore() + + _, err := s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + _, err = s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v2"}) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err), "expected AlreadyExists error, got: %v", err) +} + +func TestObjectStore_Create_SameNameDifferentWorkspace(t *testing.T) { + s := newTestStore() + + _, err := s.Create("ws-a", &testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + _, err = s.Create("ws-b", &testItem{Name: "alpha", Value: "v2"}) + require.NoError(t, err) + + gotA, err := s.Get("ws-a", "alpha") + require.NoError(t, err) + assert.Equal(t, "v1", gotA.Value) + + gotB, err := s.Get("ws-b", "alpha") + require.NoError(t, err) + assert.Equal(t, "v2", gotB.Value) +} + +func TestObjectStore_Get(t *testing.T) { + s := newTestStore() + + _, err := s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + got, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "alpha", got.Name) + assert.Equal(t, "v1", got.Value) +} + +func TestObjectStore_Get_NotFound(t *testing.T) { + s := newTestStore() + + _, err := s.Get(testWorkspace, "nonexistent") + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound error, got: %v", err) +} + +func TestObjectStore_Get_WrongWorkspace(t *testing.T) { + s := newTestStore() + + _, err := s.Create("ws-a", &testItem{Name: "alpha", Value: "v1"}) + require.NoError(t, err) + + _, err = s.Get("ws-b", "alpha") + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound for wrong workspace") +} + +func TestObjectStore_List_Empty(t *testing.T) { + s := newTestStore() + items := s.List(testWorkspace) + assert.Empty(t, items) +} + +func TestObjectStore_List(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v1"}) + _, _ = s.Create(testWorkspace, &testItem{Name: "beta", Value: "v2"}) + + items := s.List(testWorkspace) + assert.Len(t, items, 2) + + sort.Slice(items, func(i, j int) bool { return items[i].Name < items[j].Name }) + assert.Equal(t, "alpha", items[0].Name) + assert.Equal(t, "beta", items[1].Name) +} + +func TestObjectStore_List_WorkspaceIsolation(t *testing.T) { + s := newTestStore() + + _, _ = s.Create("ws-a", &testItem{Name: "alpha", Value: "v1"}) + _, _ = s.Create("ws-b", &testItem{Name: "beta", Value: "v2"}) + _, _ = s.Create("ws-a", &testItem{Name: "gamma", Value: "v3"}) + + itemsA := s.List("ws-a") + assert.Len(t, itemsA, 2) + + itemsB := s.List("ws-b") + assert.Len(t, itemsB, 1) + assert.Equal(t, "beta", itemsB[0].Name) +} + +func TestObjectStore_ListAll(t *testing.T) { + s := newTestStore() + + _, _ = s.Create("ws-a", &testItem{Name: "alpha", Value: "v1"}) + _, _ = s.Create("ws-b", &testItem{Name: "beta", Value: "v2"}) + _, _ = s.Create("ws-a", &testItem{Name: "gamma", Value: "v3"}) + + all := s.ListAll() + assert.Len(t, all, 3) +} + +func TestObjectStore_Update(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v1"}) + + updated, err := s.Update(testWorkspace, &testItem{Name: "alpha", Value: "v2"}) + require.NoError(t, err) + assert.Equal(t, "v2", updated.Value) + + got, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "v2", got.Value) +} + +func TestObjectStore_Update_NotFound(t *testing.T) { + s := newTestStore() + + _, err := s.Update(testWorkspace, &testItem{Name: "nonexistent", Value: "v1"}) + require.Error(t, err) + assert.True(t, types.IsNotFound(err), "expected NotFound error, got: %v", err) +} + +func TestObjectStore_Delete(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v1"}) + s.Delete(testWorkspace, "alpha") + + _, err := s.Get(testWorkspace, "alpha") + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestObjectStore_Delete_Idempotent(_ *testing.T) { + s := newTestStore() + + s.Delete(testWorkspace, "nonexistent") + + _, _ = s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v1"}) + s.Delete(testWorkspace, "alpha") + s.Delete(testWorkspace, "alpha") +} + +func TestObjectStore_Insert(t *testing.T) { + s := newTestStore() + + s.Insert(testWorkspace, &testItem{Name: "alpha", Value: "v1"}) + + got, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) +} + +func TestObjectStore_Insert_Overwrites(t *testing.T) { + s := newTestStore() + + s.Insert(testWorkspace, &testItem{Name: "alpha", Value: "v1"}) + s.Insert(testWorkspace, &testItem{Name: "alpha", Value: "v2"}) + + got, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "v2", got.Value) +} + +func TestObjectStore_DeepCopy_OnCreate(t *testing.T) { + s := newTestStore() + + original := &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}} + created, err := s.Create(testWorkspace, original) + require.NoError(t, err) + + original.Value = "mutated" + original.Tags["env"] = "mutated" + + got, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) + + created.Value = "mutated-created" + got2, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got2.Value) +} + +func TestObjectStore_DeepCopy_OnGet(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}}) + + got, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + + got.Value = "mutated" + got.Tags["env"] = "mutated" + + got2, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got2.Value) + assert.Equal(t, "test", got2.Tags["env"]) +} + +func TestObjectStore_DeepCopy_OnList(t *testing.T) { + s := newTestStore() + + _, _ = s.Create(testWorkspace, &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}}) + + items := s.List(testWorkspace) + require.Len(t, items, 1) + + items[0].Value = "mutated" + items[0].Tags["env"] = "mutated" + + got, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) +} + +func TestObjectStore_DeepCopy_OnInsert(t *testing.T) { + s := newTestStore() + + original := &testItem{Name: "alpha", Value: "v1", Tags: map[string]string{"env": "test"}} + s.Insert(testWorkspace, original) + + original.Value = "mutated" + original.Tags["env"] = "mutated" + + got, err := s.Get(testWorkspace, "alpha") + require.NoError(t, err) + assert.Equal(t, "v1", got.Value) + assert.Equal(t, "test", got.Tags["env"]) +} diff --git a/sdk/go/openshell/v1/fake/tcp.go b/sdk/go/openshell/v1/fake/tcp.go new file mode 100644 index 0000000000..6a39030bba --- /dev/null +++ b/sdk/go/openshell/v1/fake/tcp.go @@ -0,0 +1,61 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "fmt" + "io" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// fakeTCPClient implements v1.TCPInterface. All methods return +// Unimplemented because TCP port forwarding requires a real sandbox runtime. +type fakeTCPClient struct { + closedFunc func() bool +} + +// newFakeTCPClient creates a new fakeTCPClient. +func newFakeTCPClient(closedFunc func() bool) *fakeTCPClient { + return &fakeTCPClient{closedFunc: closedFunc} +} + +// Forward returns Unimplemented. Ports outside 1-65535 are rejected with +// InvalidArgument to match the real client's behavior. +func (c *fakeTCPClient) Forward(_ context.Context, _, sandboxName string, port uint32, _ ...v1.ForwardOption) (io.ReadWriteCloser, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if port == 0 || port > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", port)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Forward is not supported by the fake client"} +} + +// Listen validates inputs then returns Unimplemented. The fake does not bind +// any local port; it checks that sandboxName is non-empty, remotePort is in +// the range 1-65535, and localPort is in the range 0-65535. +func (c *fakeTCPClient) Listen(_ context.Context, _, sandboxName string, remotePort uint32, localPort uint32, _ ...v1.ListenOption) (v1.ForwardListener, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if sandboxName == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePort == 0 || remotePort > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("port must be in range 1-65535, got %d", remotePort)} + } + if localPort > 65535 { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: fmt.Sprintf("local port must be in range 0-65535, got %d", localPort)} + } + return nil, &types.StatusError{Code: types.ErrorUnimplemented, Message: "Listen is not supported by the fake client"} +} + +// Compile-time check that fakeTCPClient implements v1.TCPInterface. +var _ v1.TCPInterface = (*fakeTCPClient)(nil) diff --git a/sdk/go/openshell/v1/fake/tcp_test.go b/sdk/go/openshell/v1/fake/tcp_test.go new file mode 100644 index 0000000000..5d254b55fd --- /dev/null +++ b/sdk/go/openshell/v1/fake/tcp_test.go @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- T019: fakeTCPClient stub tests --- + +func TestFakeTCP_Forward_ReturnsUnimplemented(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "default", "sandbox-1", 8080) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Forward_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeTCPClient(func() bool { return true }) + _, err := c.Forward(context.Background(), "default", "sandbox-1", 8080) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeTCP_Forward_WithForwardOption(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "default", "sandbox-1", 8080, v1.WithForwardServiceID("audit-svc")) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Forward_InvalidPort(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + _, err := c.Forward(context.Background(), "default", "sandbox-1", 0) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +// --- T020: fakeTCPClient.Listen tests --- + +func TestFakeTCP_Listen_EmptySandboxName(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "default", "", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeTCP_Listen_InvalidRemotePort(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := c.Listen(context.Background(), "default", "my-sandbox", tt.port, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) + }) + } +} + +func TestFakeTCP_Listen_ValidInputsReturnUnimplemented(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "default", "my-sandbox", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} + +func TestFakeTCP_Listen_ClosedReturnsUnavailable(t *testing.T) { + c := newFakeTCPClient(func() bool { return true }) + ln, err := c.Listen(context.Background(), "default", "my-sandbox", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeTCP_Listen_WithOptions(t *testing.T) { + c := newFakeTCPClient(func() bool { return false }) + ln, err := c.Listen(context.Background(), "default", "my-sandbox", 8080, 0, + v1.WithBindAddress("0.0.0.0"), + v1.WithSSHTunnel(), + v1.WithListenServiceID("svc-1"), + ) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, types.IsUnimplemented(err)) +} diff --git a/sdk/go/openshell/v1/fake/workspace.go b/sdk/go/openshell/v1/fake/workspace.go new file mode 100644 index 0000000000..026eac43a5 --- /dev/null +++ b/sdk/go/openshell/v1/fake/workspace.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +func workspaceName(ws *types.Workspace) string { + return ws.Name +} + +func copyWorkspace(ws *types.Workspace) *types.Workspace { + if ws == nil { + return nil + } + cp := *ws + cp.Labels = copyStringMap(ws.Labels) + cp.Annotations = copyStringMap(ws.Annotations) + if ws.DeletionTimestamp != nil { + t := *ws.DeletionTimestamp + cp.DeletionTimestamp = &t + } + return &cp +} + +func memberName(m *types.WorkspaceMember) string { + return m.PrincipalSubject +} + +func copyMember(m *types.WorkspaceMember) *types.WorkspaceMember { + if m == nil { + return nil + } + cp := *m + cp.Labels = copyStringMap(m.Labels) + cp.Annotations = copyStringMap(m.Annotations) + return &cp +} + +type fakeWorkspaceClient struct { + workspaceStore *objectStore[*types.Workspace] + memberStore *objectStore[*types.WorkspaceMember] + closedFunc func() bool +} + +func newFakeWorkspaceClient( + workspaceStore *objectStore[*types.Workspace], + memberStore *objectStore[*types.WorkspaceMember], + closedFunc func() bool, +) *fakeWorkspaceClient { + return &fakeWorkspaceClient{ + workspaceStore: workspaceStore, + memberStore: memberStore, + closedFunc: closedFunc, + } +} + +func (c *fakeWorkspaceClient) Create(_ context.Context, name string, labels map[string]string) (*types.Workspace, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if name == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + + ws := &types.Workspace{ + Name: name, + CreatedAt: time.Now(), + Labels: copyStringMap(labels), + ResourceVersion: 1, + Phase: types.WorkspaceActive, + } + + return c.workspaceStore.Create("", ws) +} + +func (c *fakeWorkspaceClient) Get(_ context.Context, name string) (*types.Workspace, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if name == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + return c.workspaceStore.Get("", name) +} + +// List returns all workspaces. ListOptions are accepted for interface compatibility but filtering is not implemented. +func (c *fakeWorkspaceClient) List(_ context.Context, _ ...v1.ListOptions) ([]*types.Workspace, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + return c.workspaceStore.ListAll(), nil +} + +// Delete removes a workspace. Unlike the sandbox fake (which treats delete as +// idempotent), workspace delete returns NotFound for non-existent workspaces to +// match the gateway's workspace deletion behavior. +func (c *fakeWorkspaceClient) Delete(_ context.Context, name string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if name == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + + _, existed := c.workspaceStore.DeleteAndGet("", name) + if !existed { + return &types.StatusError{Code: types.ErrorNotFound, Message: name + " not found"} + } + c.memberStore.DeleteWorkspace(name) + return nil +} + +func (c *fakeWorkspaceClient) AddMember(_ context.Context, workspace, principalSubject string, role types.WorkspaceRole) (*types.WorkspaceMember, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if workspace == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + if principalSubject == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "principal subject must not be empty"} + } + if role != types.WorkspaceRoleAdmin && role != types.WorkspaceRoleUser { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "role must be Admin or User"} + } + + member := &types.WorkspaceMember{ + Name: principalSubject, + CreatedAt: time.Now(), + ResourceVersion: 1, + PrincipalSubject: principalSubject, + Role: role, + } + + return c.memberStore.Create(workspace, member) +} + +func (c *fakeWorkspaceClient) RemoveMember(_ context.Context, workspace, principalSubject string) error { + if c.closedFunc() { + return &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if workspace == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + if principalSubject == "" { + return &types.StatusError{Code: types.ErrorInvalidArgument, Message: "principal subject must not be empty"} + } + + _, existed := c.memberStore.DeleteAndGet(workspace, principalSubject) + if !existed { + return &types.StatusError{Code: types.ErrorNotFound, Message: principalSubject + " not found"} + } + return nil +} + +// ListMembers returns all members for the workspace. ListOptions are accepted for interface compatibility but filtering is not implemented. +func (c *fakeWorkspaceClient) ListMembers(_ context.Context, workspace string, _ ...v1.ListOptions) ([]*types.WorkspaceMember, error) { + if c.closedFunc() { + return nil, &types.StatusError{Code: types.ErrorUnavailable, Message: "client is closed"} + } + if workspace == "" { + return nil, &types.StatusError{Code: types.ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + return c.memberStore.List(workspace), nil +} diff --git a/sdk/go/openshell/v1/fake/workspace_test.go b/sdk/go/openshell/v1/fake/workspace_test.go new file mode 100644 index 0000000000..32ebf6434a --- /dev/null +++ b/sdk/go/openshell/v1/fake/workspace_test.go @@ -0,0 +1,293 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package fake + +import ( + "context" + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkspaceDelete_RemovesMembers(t *testing.T) { + fc := NewClient() + ctx := context.Background() + _, err := fc.Workspaces().Create(ctx, "team", nil) + require.NoError(t, err) + _, err = fc.Workspaces().AddMember(ctx, "team", "alice", types.WorkspaceRoleUser) + require.NoError(t, err) + require.NoError(t, fc.Workspaces().Delete(ctx, "team")) + + members, err := fc.Workspaces().ListMembers(ctx, "team") + require.NoError(t, err) + assert.Empty(t, members) +} + +func TestFakeWorkspace_Create(t *testing.T) { + fc := NewClient() + ws, err := fc.Workspaces().Create(context.Background(), "test-ws", map[string]string{"team": "platform"}) + + require.NoError(t, err) + require.NotNil(t, ws) + assert.Equal(t, "test-ws", ws.Name) + assert.Equal(t, map[string]string{"team": "platform"}, ws.Labels) + assert.Equal(t, types.WorkspaceActive, ws.Phase) + assert.Equal(t, uint64(1), ws.ResourceVersion) +} + +func TestFakeWorkspace_Create_EmptyName(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().Create(context.Background(), "", nil) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_Create_AlreadyExists(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().Create(context.Background(), "dup-ws", nil) + require.NoError(t, err) + + _, err = fc.Workspaces().Create(context.Background(), "dup-ws", nil) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestFakeWorkspace_Create_DeepCopy(t *testing.T) { + fc := NewClient() + labels := map[string]string{"env": "test"} + ws, err := fc.Workspaces().Create(context.Background(), "ws", labels) + require.NoError(t, err) + + labels["env"] = "mutated" + assert.Equal(t, "test", ws.Labels["env"]) + + got, err := fc.Workspaces().Get(context.Background(), "ws") + require.NoError(t, err) + assert.Equal(t, "test", got.Labels["env"]) +} + +func TestFakeWorkspace_Get(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().Create(context.Background(), "my-ws", nil) + require.NoError(t, err) + + ws, err := fc.Workspaces().Get(context.Background(), "my-ws") + require.NoError(t, err) + assert.Equal(t, "my-ws", ws.Name) +} + +func TestFakeWorkspace_Get_EmptyName(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().Get(context.Background(), "") + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_Get_NotFound(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().Get(context.Background(), "missing") + + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestFakeWorkspace_List(t *testing.T) { + fc := NewClient() + _, _ = fc.Workspaces().Create(context.Background(), "ws-1", nil) + _, _ = fc.Workspaces().Create(context.Background(), "ws-2", nil) + + workspaces, err := fc.Workspaces().List(context.Background()) + require.NoError(t, err) + assert.Len(t, workspaces, 2) +} + +func TestFakeWorkspace_List_Empty(t *testing.T) { + fc := NewClient() + workspaces, err := fc.Workspaces().List(context.Background()) + require.NoError(t, err) + assert.Empty(t, workspaces) +} + +func TestFakeWorkspace_Delete(t *testing.T) { + fc := NewClient() + _, _ = fc.Workspaces().Create(context.Background(), "del-ws", nil) + + err := fc.Workspaces().Delete(context.Background(), "del-ws") + require.NoError(t, err) + + _, err = fc.Workspaces().Get(context.Background(), "del-ws") + assert.True(t, types.IsNotFound(err)) +} + +func TestFakeWorkspace_Delete_EmptyName(t *testing.T) { + fc := NewClient() + err := fc.Workspaces().Delete(context.Background(), "") + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_Delete_NotFound(t *testing.T) { + fc := NewClient() + err := fc.Workspaces().Delete(context.Background(), "missing") + + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestFakeWorkspace_AddMember(t *testing.T) { + fc := NewClient() + m, err := fc.Workspaces().AddMember(context.Background(), "ws", "user@example.com", types.WorkspaceRoleAdmin) + + require.NoError(t, err) + require.NotNil(t, m) + assert.Equal(t, "user@example.com", m.PrincipalSubject) + assert.Equal(t, types.WorkspaceRoleAdmin, m.Role) +} + +func TestFakeWorkspace_AddMember_EmptyWorkspace(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().AddMember(context.Background(), "", "user@example.com", types.WorkspaceRoleAdmin) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_AddMember_EmptySubject(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().AddMember(context.Background(), "ws", "", types.WorkspaceRoleAdmin) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_AddMember_InvalidRole(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().AddMember(context.Background(), "ws", "user@example.com", types.WorkspaceRole("invalid")) + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_AddMember_AlreadyExists(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().AddMember(context.Background(), "ws", "user@example.com", types.WorkspaceRoleAdmin) + require.NoError(t, err) + + _, err = fc.Workspaces().AddMember(context.Background(), "ws", "user@example.com", types.WorkspaceRoleUser) + require.Error(t, err) + assert.True(t, types.IsAlreadyExists(err)) +} + +func TestFakeWorkspace_RemoveMember(t *testing.T) { + fc := NewClient() + _, _ = fc.Workspaces().AddMember(context.Background(), "ws", "user@example.com", types.WorkspaceRoleAdmin) + + err := fc.Workspaces().RemoveMember(context.Background(), "ws", "user@example.com") + require.NoError(t, err) + + members, err := fc.Workspaces().ListMembers(context.Background(), "ws") + require.NoError(t, err) + assert.Empty(t, members) +} + +func TestFakeWorkspace_RemoveMember_EmptyWorkspace(t *testing.T) { + fc := NewClient() + err := fc.Workspaces().RemoveMember(context.Background(), "", "user@example.com") + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_RemoveMember_EmptySubject(t *testing.T) { + fc := NewClient() + err := fc.Workspaces().RemoveMember(context.Background(), "ws", "") + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_RemoveMember_NotFound(t *testing.T) { + fc := NewClient() + err := fc.Workspaces().RemoveMember(context.Background(), "ws", "missing@example.com") + + require.Error(t, err) + assert.True(t, types.IsNotFound(err)) +} + +func TestFakeWorkspace_ListMembers(t *testing.T) { + fc := NewClient() + _, _ = fc.Workspaces().AddMember(context.Background(), "ws", "user1@example.com", types.WorkspaceRoleAdmin) + _, _ = fc.Workspaces().AddMember(context.Background(), "ws", "user2@example.com", types.WorkspaceRoleUser) + + members, err := fc.Workspaces().ListMembers(context.Background(), "ws") + require.NoError(t, err) + assert.Len(t, members, 2) +} + +func TestFakeWorkspace_ListMembers_EmptyWorkspace(t *testing.T) { + fc := NewClient() + _, err := fc.Workspaces().ListMembers(context.Background(), "") + + require.Error(t, err) + assert.True(t, types.IsInvalidArgument(err)) +} + +func TestFakeWorkspace_ListMembers_Isolation(t *testing.T) { + fc := NewClient() + _, _ = fc.Workspaces().AddMember(context.Background(), "ws-a", "user@example.com", types.WorkspaceRoleAdmin) + _, _ = fc.Workspaces().AddMember(context.Background(), "ws-b", "other@example.com", types.WorkspaceRoleUser) + + membersA, err := fc.Workspaces().ListMembers(context.Background(), "ws-a") + require.NoError(t, err) + assert.Len(t, membersA, 1) + assert.Equal(t, "user@example.com", membersA[0].PrincipalSubject) + + membersB, err := fc.Workspaces().ListMembers(context.Background(), "ws-b") + require.NoError(t, err) + assert.Len(t, membersB, 1) + assert.Equal(t, "other@example.com", membersB[0].PrincipalSubject) +} + +func TestFakeWorkspace_Closed(t *testing.T) { + fc := NewClient() + _ = fc.Close() + + _, err := fc.Workspaces().Create(context.Background(), "ws", nil) + assert.True(t, types.IsUnavailable(err)) + + _, err = fc.Workspaces().Get(context.Background(), "ws") + assert.True(t, types.IsUnavailable(err)) + + _, err = fc.Workspaces().List(context.Background()) + assert.True(t, types.IsUnavailable(err)) + + err = fc.Workspaces().Delete(context.Background(), "ws") + assert.True(t, types.IsUnavailable(err)) + + _, err = fc.Workspaces().AddMember(context.Background(), "ws", "user", types.WorkspaceRoleAdmin) + assert.True(t, types.IsUnavailable(err)) + + err = fc.Workspaces().RemoveMember(context.Background(), "ws", "user") + assert.True(t, types.IsUnavailable(err)) + + _, err = fc.Workspaces().ListMembers(context.Background(), "ws") + assert.True(t, types.IsUnavailable(err)) +} + +func TestFakeWorkspace_AddWorkspace(t *testing.T) { + fc := NewClient() + fc.AddWorkspace(&types.Workspace{ + Name: "preseeded", + Phase: types.WorkspaceActive, + }) + + ws, err := fc.Workspaces().Get(context.Background(), "preseeded") + require.NoError(t, err) + assert.Equal(t, "preseeded", ws.Name) +} diff --git a/sdk/go/openshell/v1/file.go b/sdk/go/openshell/v1/file.go index 0893c9c6cb..31a9a2e413 100644 --- a/sdk/go/openshell/v1/file.go +++ b/sdk/go/openshell/v1/file.go @@ -3,7 +3,14 @@ package v1 -import "context" +import ( + "context" + "errors" +) + +// ErrTransportNotAvailable indicates that this SDK build has no file-transfer +// transport. Callers can detect it with errors.Is. +var ErrTransportNotAvailable = errors.New("openshell: file transport not available") // FileInterface defines file transfer operations on sandboxes. // Methods accept a sandbox name and resolve it to an ID internally. diff --git a/sdk/go/openshell/v1/file_client.go b/sdk/go/openshell/v1/file_client.go new file mode 100644 index 0000000000..7e9edad176 --- /dev/null +++ b/sdk/go/openshell/v1/file_client.go @@ -0,0 +1,125 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type fileClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface + transport sshTransport +} + +type sshTransport interface { + available() bool + upload(ctx context.Context, session *pb.CreateSshSessionResponse, localPath, remotePath string) error + download(ctx context.Context, session *pb.CreateSshSessionResponse, remotePath, localPath string) error +} + +func newFileClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface) *fileClient { + return &fileClient{ + client: pb.NewOpenShellClient(conn), + sandboxes: sandboxes, + transport: &defaultSSHTransport{}, + } +} + +func (f *fileClient) Upload(ctx context.Context, workspace, sandboxName string, localPath string, remotePath string) error { + if !f.transport.available() { + return fmt.Errorf("upload: %w", ErrTransportNotAvailable) + } + if sandboxName == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePath == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "remote path must not be empty"} + } + + info, err := os.Stat(localPath) + if err != nil { + return fmt.Errorf("local file error: %w", err) + } + if info.IsDir() { + return fmt.Errorf("local path is a directory, not a file: %s", localPath) + } + + sb, err := f.sandboxes.Get(ctx, workspace, sandboxName) + if err != nil { + return err + } + + session, err := f.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return converter.FromGRPCError(err) + } + + defer func() { + revokeCtx, revokeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer revokeCancel() + _, _ = f.client.RevokeSshSession(revokeCtx, &pb.RevokeSshSessionRequest{ + Token: session.GetToken(), + }) + }() + + return f.transport.upload(ctx, session, localPath, remotePath) +} + +func (f *fileClient) Download(ctx context.Context, workspace, sandboxName string, remotePath string, localPath string) error { + if !f.transport.available() { + return fmt.Errorf("download: %w", ErrTransportNotAvailable) + } + if sandboxName == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePath == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "remote path must not be empty"} + } + + sb, err := f.sandboxes.Get(ctx, workspace, sandboxName) + if err != nil { + return err + } + + session, err := f.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sb.ID, + }) + if err != nil { + return converter.FromGRPCError(err) + } + + defer func() { + revokeCtx, revokeCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer revokeCancel() + _, _ = f.client.RevokeSshSession(revokeCtx, &pb.RevokeSshSessionRequest{ + Token: session.GetToken(), + }) + }() + + return f.transport.download(ctx, session, remotePath, localPath) +} + +type defaultSSHTransport struct{} + +func (t *defaultSSHTransport) available() bool { return false } + +func (t *defaultSSHTransport) upload(_ context.Context, session *pb.CreateSshSessionResponse, localPath, remotePath string) error { + return fmt.Errorf("SSH transport to %s:%d not implemented (local: %s -> remote: %s)", + session.GetGatewayHost(), session.GetGatewayPort(), localPath, remotePath) +} + +func (t *defaultSSHTransport) download(_ context.Context, session *pb.CreateSshSessionResponse, remotePath, localPath string) error { + return fmt.Errorf("SSH transport to %s:%d not implemented (remote: %s -> local: %s)", + session.GetGatewayHost(), session.GetGatewayPort(), remotePath, localPath) +} diff --git a/sdk/go/openshell/v1/file_client_test.go b/sdk/go/openshell/v1/file_client_test.go new file mode 100644 index 0000000000..ac08c8dec0 --- /dev/null +++ b/sdk/go/openshell/v1/file_client_test.go @@ -0,0 +1,343 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +type availableSSHTransport struct{} + +func (availableSSHTransport) available() bool { return true } +func (availableSSHTransport) upload(context.Context, *pb.CreateSshSessionResponse, string, string) error { + return nil +} +func (availableSSHTransport) download(context.Context, *pb.CreateSshSessionResponse, string, string) error { + return nil +} + +type mockFileServer struct { + pb.UnimplementedOpenShellServer + createResp *pb.CreateSshSessionResponse + createErr error + revokeResp *pb.RevokeSshSessionResponse + revokeErr error + lastCreateReq *pb.CreateSshSessionRequest + lastRevokeReq *pb.RevokeSshSessionRequest + createCallCount int + revokeCallCount int +} + +func newMockFileServer() *mockFileServer { + return &mockFileServer{} +} + +func (s *mockFileServer) CreateSshSession(_ context.Context, req *pb.CreateSshSessionRequest) (*pb.CreateSshSessionResponse, error) { //nolint:revive // method name matches proto interface + s.lastCreateReq = req + s.createCallCount++ + if s.createErr != nil { + return nil, s.createErr + } + return s.createResp, nil +} + +func (s *mockFileServer) RevokeSshSession(_ context.Context, req *pb.RevokeSshSessionRequest) (*pb.RevokeSshSessionResponse, error) { //nolint:revive // method name matches proto interface + s.lastRevokeReq = req + s.revokeCallCount++ + if s.revokeErr != nil { + return nil, s.revokeErr + } + return s.revokeResp, nil +} + +func setupFileTest(t *testing.T, mock *mockFileServer) (*fileClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + client := newFileClient(conn, &stubSandboxResolver{}) + client.transport = availableSSHTransport{} + return client, func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- T051: Upload and Download tests --- + +func TestFileUpload(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-test-sandbox", + Token: "session-token-123", + GatewayHost: "gateway.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("file content"), 0644)) + + err := client.Upload(context.Background(), "default", "test-sandbox", localPath, "/remote/upload.txt") + + require.NoError(t, err) + assert.Equal(t, "sb-test-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, 1, mock.createCallCount) +} + +func TestFileTransfer_DefaultTransportReturnsUnavailableBeforeRPC(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + client.transport = &defaultSSHTransport{} + + err := client.Upload(context.Background(), "default", "sandbox", "/missing/local/file", "/remote/file") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTransportNotAvailable)) + assert.Equal(t, 0, mock.createCallCount) + + err = client.Download(context.Background(), "default", "sandbox", "/remote/file", "/local/file") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTransportNotAvailable)) + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileUpload_CreateSessionError(t *testing.T) { + mock := newMockFileServer() + mock.createErr = status.Error(codes.NotFound, "sandbox not found") + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err := client.Upload(context.Background(), "default", "test-sandbox", localPath, "/remote/file.txt") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestFileDownload(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-test-sandbox", + Token: "session-token-456", + GatewayHost: "gateway.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "default", "test-sandbox", "/remote/file.txt", localPath) + + require.NoError(t, err) + assert.Equal(t, "sb-test-sandbox", mock.lastCreateReq.GetSandboxId()) + assert.Equal(t, 1, mock.createCallCount) +} + +func TestFileDownload_CreateSessionError(t *testing.T) { + mock := newMockFileServer() + mock.createErr = status.Error(codes.PermissionDenied, "access denied") + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "default", "test-sandbox", "/remote/file.txt", localPath) + + require.Error(t, err) + assert.True(t, IsPermissionDenied(err)) +} + +// --- T052: Upload error cases --- + +func TestFileUpload_NonExistentLocalFile(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + err := client.Upload(context.Background(), "default", "test-sandbox", "/nonexistent/file.txt", "/remote/file.txt") + + require.Error(t, err) + // Should fail before contacting gateway + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileUpload_LocalPathIsDirectory(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + + err := client.Upload(context.Background(), "default", "test-sandbox", tmpDir, "/remote/file.txt") + + require.Error(t, err) + // Should fail before contacting gateway + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileUpload_EmptySandboxName(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "file.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err := client.Upload(context.Background(), "default", "", localPath, "/remote/file.txt") + + require.Error(t, err) + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileDownload_EmptyRemotePath(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "download.txt") + + err := client.Download(context.Background(), "default", "test-sandbox", "", localPath) + + require.Error(t, err) + assert.Equal(t, 0, mock.createCallCount) +} + +// --- Name-to-ID resolution tests --- + +func TestFileUpload_ResolvesNameToID(t *testing.T) { + mock := newMockFileServer() + mock.createResp = &pb.CreateSshSessionResponse{ + SandboxId: "sb-my-sandbox", + Token: "token", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + } + mock.revokeResp = &pb.RevokeSshSessionResponse{Revoked: true} + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + _ = client.Upload(context.Background(), "default", "my-sandbox", localPath, "/remote/file.txt") + + // Verify the proto request contains the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", mock.lastCreateReq.GetSandboxId()) +} + +func TestFileUpload_ResolutionError(t *testing.T) { + mock := newMockFileServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newFileClient(conn, resolver) + client.transport = availableSSHTransport{} + + tmpDir := t.TempDir() + localPath := filepath.Join(tmpDir, "upload.txt") + require.NoError(t, os.WriteFile(localPath, []byte("content"), 0644)) + + err = client.Upload(context.Background(), "default", "nonexistent", localPath, "/remote/file.txt") + require.Error(t, err) + assert.True(t, IsNotFound(err)) + assert.Equal(t, 0, mock.createCallCount) +} + +func TestFileDownload_ResolvesNameToID(t *testing.T) { + mock := newMockFileServer() + client, cleanup := setupFileTest(t, mock) + defer cleanup() + + localPath := filepath.Join(t.TempDir(), "downloaded.txt") + _ = client.Download(context.Background(), "default", "my-sandbox", "/remote/file.txt", localPath) + + assert.Equal(t, "sb-my-sandbox", mock.lastCreateReq.GetSandboxId()) +} + +func TestFileDownload_ResolutionError(t *testing.T) { + mock := newMockFileServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newFileClient(conn, resolver) + client.transport = availableSSHTransport{} + + localPath := filepath.Join(t.TempDir(), "downloaded.txt") + err = client.Download(context.Background(), "default", "nonexistent", "/remote/file.txt", localPath) + require.Error(t, err) + assert.True(t, IsNotFound(err)) + assert.Equal(t, 0, mock.createCallCount) +} diff --git a/sdk/go/openshell/v1/gateway/config.go b/sdk/go/openshell/v1/gateway/config.go new file mode 100644 index 0000000000..c0813986e9 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/config.go @@ -0,0 +1,157 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" +) + +// AuthMode represents the authentication mode configured for a gateway. +type AuthMode string + +// Known auth mode values matching the Rust CLI's gateway configuration. +const ( + // AuthModeNone indicates no authentication (default when auth_mode is + // unset or explicitly "none"). + AuthModeNone AuthMode = "" + + // AuthModePlaintext indicates an insecure plaintext connection with + // no TLS and no authentication. + AuthModePlaintext AuthMode = "plaintext" + + // AuthModeCloudflareJWT indicates Cloudflare Access JWT authentication + // using an edge token loaded from disk. + AuthModeCloudflareJWT AuthMode = "cloudflare_jwt" + + // AuthModeOIDC indicates OpenID Connect authentication using a + // refreshable token bundle loaded from disk. + AuthModeOIDC AuthMode = "oidc" + + // AuthModeMTLS indicates mutual TLS authentication. Currently + // unsupported; returns [ErrUnsupportedAuthMode] with guidance. + AuthModeMTLS AuthMode = "mtls" +) + +// ConfigSource identifies where a gateway configuration was found. +type ConfigSource string + +const ( + // SourceUser indicates the gateway was found in the user config + // directory ($XDG_CONFIG_HOME/openshell/gateways/). + SourceUser ConfigSource = "user" + + // SourceSystem indicates the gateway was found in the system config + // directory (/etc/openshell/gateways/). + SourceSystem ConfigSource = "system" +) + +// Config is a parsed representation of a gateway's on-disk metadata.json. +// It is an immutable snapshot captured at load time; subsequent changes to +// the on-disk files are not reflected. +type Config struct { + // Name is the validated gateway name. + Name string + + // Endpoint is the host:port address of the gateway. + Endpoint string + + // AuthMode is the resolved authentication mode. + AuthMode AuthMode + + // Source indicates whether the config came from the user or system + // directory. + Source ConfigSource + + // Dir is the absolute path to the gateway config directory. + Dir string + + // OIDCIssuer is the OIDC provider's issuer URL read from + // metadata.json. Empty when the gateway does not use OIDC auth. + OIDCIssuer string + + // OIDCClientID is the OAuth2 client ID read from metadata.json. + // Empty when the gateway does not use OIDC auth. + OIDCClientID string +} + +// Info is a lightweight summary of a gateway for listing purposes. +// It does not load tokens or validate config completeness. +type Info struct { + // Name is the gateway name derived from the directory listing. + Name string + + // Active indicates whether this is the currently active gateway. + Active bool + + // Source indicates whether the gateway is from the user or system + // directory. + Source ConfigSource +} + +// metadataJSON is the on-disk representation of metadata.json. +// Unknown fields are silently ignored for forward compatibility. +type metadataJSON struct { + Endpoint string `json:"gateway_endpoint"` + AuthMode string `json:"auth_mode"` + Name string `json:"name"` + OIDCIssuer string `json:"oidc_issuer"` + OIDCClientID string `json:"oidc_client_id"` +} + +// parseAuthMode converts a raw auth_mode string to the typed AuthMode. +// Empty string and "none" both map to AuthModeNone. +func parseAuthMode(raw string) (AuthMode, error) { + switch raw { + case "", "none": + return AuthModeNone, nil + case "plaintext": + return AuthModePlaintext, nil + case "cloudflare_jwt": + return AuthModeCloudflareJWT, nil + case "oidc": + return AuthModeOIDC, nil + case "mtls": + return AuthModeMTLS, nil + default: + return "", fmt.Errorf("%w: %q", ErrUnsupportedAuthMode, raw) + } +} + +// parseMetadata reads and parses metadata.json from the given gateway +// directory. Unknown fields are silently ignored for forward compatibility +// with newer Rust CLI versions. +func parseMetadata(dir string) (*Config, error) { + path := filepath.Join(dir, "metadata.json") + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrConfigParse, err) + } + + var meta metadataJSON + if err := json.Unmarshal(data, &meta); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s: %v", ErrConfigParse, path, err) + } + + if meta.Endpoint == "" { + return nil, fmt.Errorf("%w: missing gateway_endpoint in %s", ErrConfigParse, path) + } + + mode, err := parseAuthMode(meta.AuthMode) + if err != nil { + return nil, err + } + + return &Config{ + Name: meta.Name, + Endpoint: meta.Endpoint, + AuthMode: mode, + Dir: dir, + OIDCIssuer: meta.OIDCIssuer, + OIDCClientID: meta.OIDCClientID, + }, nil +} diff --git a/sdk/go/openshell/v1/gateway/config_test.go b/sdk/go/openshell/v1/gateway/config_test.go new file mode 100644 index 0000000000..8411d2eef9 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/config_test.go @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T010: metadata.json parsing tests --- + +func TestParseMetadata_ValidConfig(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"localhost:8080","auth_mode":"none","name":"prod"}`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "prod", cfg.Name) + assert.Equal(t, "localhost:8080", cfg.Endpoint) + assert.Equal(t, AuthModeNone, cfg.AuthMode) + assert.Equal(t, dir, cfg.Dir) +} + +func TestParseMetadata_EmptyAuthMode(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"host:443"}`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, AuthModeNone, cfg.AuthMode) +} + +func TestParseMetadata_AllAuthModes(t *testing.T) { + cases := []struct { + mode string + expected AuthMode + }{ + {"", AuthModeNone}, + {"none", AuthModeNone}, + {"plaintext", AuthModePlaintext}, + {"cloudflare_jwt", AuthModeCloudflareJWT}, + {"oidc", AuthModeOIDC}, + {"mtls", AuthModeMTLS}, + } + + for _, tc := range cases { + t.Run("mode_"+tc.mode, func(t *testing.T) { + dir := t.TempDir() + if tc.mode == "" { + writeJSON(t, dir, `{"gateway_endpoint":"host:443"}`) + } else { + writeJSON(t, dir, `{"gateway_endpoint":"host:443","auth_mode":"`+tc.mode+`"}`) + } + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, tc.expected, cfg.AuthMode) + }) + } +} + +func TestParseMetadata_MissingEndpoint(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"auth_mode":"none","name":"prod"}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) + assert.Contains(t, err.Error(), "missing gateway_endpoint") +} + +func TestParseMetadata_MissingFile(t *testing.T) { + dir := t.TempDir() + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) +} + +func TestParseMetadata_MalformedJSON(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{invalid json}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrConfigParse) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestParseMetadata_UnknownFieldsIgnored(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"none", + "name":"prod", + "future_field":"some_value", + "another_new_thing": 42 + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "prod", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) +} + +func TestParseMetadata_UnsupportedAuthMode(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{"gateway_endpoint":"host:443","auth_mode":"kerberos"}`) + + _, err := parseMetadata(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "kerberos") +} + +func TestParseAuthMode(t *testing.T) { + cases := []struct { + input string + expected AuthMode + wantErr bool + }{ + {"", AuthModeNone, false}, + {"none", AuthModeNone, false}, + {"plaintext", AuthModePlaintext, false}, + {"cloudflare_jwt", AuthModeCloudflareJWT, false}, + {"oidc", AuthModeOIDC, false}, + {"mtls", AuthModeMTLS, false}, + {"unknown", "", true}, + {"NONE", "", true}, // case-sensitive + } + + for _, tc := range cases { + t.Run("input_"+tc.input, func(t *testing.T) { + mode, err := parseAuthMode(tc.input) + if tc.wantErr { + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + } else { + require.NoError(t, err) + assert.Equal(t, tc.expected, mode) + } + }) + } +} + +// --- T010: OIDC config field tests --- + +func TestParseMetadata_OIDCFields(t *testing.T) { + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"oidc", + "name":"oidc-gw", + "oidc_issuer":"https://auth.example.com", + "oidc_client_id":"my-client-id" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "oidc-gw", cfg.Name) + assert.Equal(t, AuthModeOIDC, cfg.AuthMode) + assert.Equal(t, "https://auth.example.com", cfg.OIDCIssuer) + assert.Equal(t, "my-client-id", cfg.OIDCClientID) +} + +func TestParseMetadata_OIDCFieldsMissing(t *testing.T) { + // When OIDC fields are absent (older gateway or non-OIDC mode), + // the Config should have empty strings for OIDCIssuer/OIDCClientID. + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"cloudflare_jwt", + "name":"legacy-gw" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "", cfg.OIDCIssuer) + assert.Equal(t, "", cfg.OIDCClientID) +} + +func TestParseMetadata_OIDCFieldsEmpty(t *testing.T) { + // Explicit empty strings for OIDC fields should be handled + // gracefully (backward compatibility). + dir := t.TempDir() + writeJSON(t, dir, `{ + "gateway_endpoint":"host:443", + "auth_mode":"oidc", + "oidc_issuer":"", + "oidc_client_id":"" + }`) + + cfg, err := parseMetadata(dir) + require.NoError(t, err) + assert.Equal(t, "", cfg.OIDCIssuer) + assert.Equal(t, "", cfg.OIDCClientID) +} + +// writeJSON is a test helper that writes a metadata.json file. +func writeJSON(t *testing.T, dir, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, "metadata.json"), []byte(content), 0o644) + require.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/gateway/doc.go b/sdk/go/openshell/v1/gateway/doc.go new file mode 100644 index 0000000000..ef060530d7 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/doc.go @@ -0,0 +1,75 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package gateway reads on-disk gateway configurations created by the +// OpenShell Rust CLI and constructs fully wired SDK clients. +// +// The package resolves XDG config paths, validates gateway names, loads +// tokens lazily, maps auth modes to existing auth providers, and provides +// one-call convenience constructors. This eliminates 20+ lines of +// boilerplate for Go programs connecting to gateways managed by the CLI. +// +// # Quick Start +// +// Connect to a named gateway: +// +// client, err := gateway.NewClient("prod") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Connect to the active gateway (set via `openshell gateway use`): +// +// client, err := gateway.NewClient("") +// if err != nil { +// log.Fatal(err) +// } +// defer client.Close() +// +// Inspect configuration without creating a client: +// +// cfg, err := gateway.LoadConfig("staging") +// if err != nil { +// log.Fatal(err) +// } +// fmt.Printf("Endpoint: %s, Auth: %s\n", cfg.Endpoint, cfg.AuthMode) +// +// List all configured gateways: +// +// gateways, err := gateway.ListGateways() +// if err != nil { +// log.Fatal(err) +// } +// for _, gw := range gateways { +// fmt.Printf("%s (active=%v, source=%s)\n", gw.Name, gw.Active, gw.Source) +// } +// +// # On-Disk Layout +// +// The package reads gateway metadata from the following locations: +// +// $XDG_CONFIG_HOME/openshell/gateways//metadata.json (user) +// /etc/openshell/gateways//metadata.json (system) +// +// Token files (edge_token, cf_token, oidc_token.json) sit alongside +// metadata.json and are loaded lazily on first authentication attempt. +// +// # Error Handling +// +// The package provides typed errors for precise failure classification: +// +// - [ErrGatewayNotFound]: no gateway directory found +// - [ErrConfigParse]: metadata.json missing or malformed +// - [ErrTokenLoad]: token file missing or unreadable +// - [ErrUnsupportedAuthMode]: unrecognized auth_mode value +// - [ErrInvalidGatewayName]: name fails validation +// - [ErrNoActiveGateway]: no active gateway configured +// +// All errors support [errors.Is] for classification. +// +// # Thread Safety +// +// All exported functions are safe for concurrent use from multiple +// goroutines. +package gateway diff --git a/sdk/go/openshell/v1/gateway/errors.go b/sdk/go/openshell/v1/gateway/errors.go new file mode 100644 index 0000000000..ef26774617 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/errors.go @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import "errors" + +// Sentinel errors for gateway configuration failures. All wrapped errors +// returned by this package support classification via [errors.Is]. +var ( + // ErrGatewayNotFound is returned when no gateway directory exists + // in either the user or system config paths. + ErrGatewayNotFound = errors.New("gateway: not found") + + // ErrConfigParse is returned when metadata.json is missing, + // unreadable, or contains invalid JSON. + ErrConfigParse = errors.New("gateway: config parse error") + + // ErrTokenLoad is returned when a token file (edge_token, + // oidc_token.json) is missing, unreadable, or malformed. + ErrTokenLoad = errors.New("gateway: token load error") + + // ErrUnsupportedAuthMode is returned when the auth_mode value in + // metadata.json is not recognized (not none, plaintext, + // cloudflare_jwt, oidc, or mtls). + ErrUnsupportedAuthMode = errors.New("gateway: unsupported auth mode") + + // ErrInvalidGatewayName is returned when a gateway name fails + // validation (empty, contains path separators, dots, or + // non-ASCII-alnum-dash-underscore characters). + ErrInvalidGatewayName = errors.New("gateway: invalid gateway name") + + // ErrNoActiveGateway is returned when no active gateway is + // configured (active_gateway file missing or empty). + ErrNoActiveGateway = errors.New("gateway: no active gateway") +) diff --git a/sdk/go/openshell/v1/gateway/errors_test.go b/sdk/go/openshell/v1/gateway/errors_test.go new file mode 100644 index 0000000000..09d8b40d6d --- /dev/null +++ b/sdk/go/openshell/v1/gateway/errors_test.go @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +// --- T011: Error type tests --- + +func TestSentinelErrors_ErrorsIs(t *testing.T) { + sentinels := []struct { + name string + err error + }{ + {"ErrGatewayNotFound", ErrGatewayNotFound}, + {"ErrConfigParse", ErrConfigParse}, + {"ErrTokenLoad", ErrTokenLoad}, + {"ErrUnsupportedAuthMode", ErrUnsupportedAuthMode}, + {"ErrInvalidGatewayName", ErrInvalidGatewayName}, + {"ErrNoActiveGateway", ErrNoActiveGateway}, + } + + for _, tc := range sentinels { + t.Run(tc.name+"_direct", func(t *testing.T) { + assert.True(t, errors.Is(tc.err, tc.err), + "errors.Is should match sentinel directly") + }) + + t.Run(tc.name+"_wrapped", func(t *testing.T) { + wrapped := fmt.Errorf("context: %w", tc.err) + assert.True(t, errors.Is(wrapped, tc.err), + "errors.Is should match through fmt.Errorf wrapping") + }) + + t.Run(tc.name+"_double_wrapped", func(t *testing.T) { + inner := fmt.Errorf("inner: %w", tc.err) + outer := fmt.Errorf("outer: %w", inner) + assert.True(t, errors.Is(outer, tc.err), + "errors.Is should match through double wrapping") + }) + } +} + +func TestSentinelErrors_NotConfused(t *testing.T) { + // Verify that different sentinel errors are not equal. + pairs := []struct { + a, b error + }{ + {ErrGatewayNotFound, ErrConfigParse}, + {ErrConfigParse, ErrTokenLoad}, + {ErrTokenLoad, ErrUnsupportedAuthMode}, + {ErrUnsupportedAuthMode, ErrInvalidGatewayName}, + {ErrInvalidGatewayName, ErrNoActiveGateway}, + {ErrNoActiveGateway, ErrGatewayNotFound}, + } + + for _, tc := range pairs { + t.Run(tc.a.Error()+"_vs_"+tc.b.Error(), func(t *testing.T) { + assert.False(t, errors.Is(tc.a, tc.b), + "different sentinels must not match") + }) + } +} + +func TestSentinelErrors_HaveMessages(t *testing.T) { + sentinels := []error{ + ErrGatewayNotFound, + ErrConfigParse, + ErrTokenLoad, + ErrUnsupportedAuthMode, + ErrInvalidGatewayName, + ErrNoActiveGateway, + } + + for _, err := range sentinels { + t.Run(err.Error(), func(t *testing.T) { + msg := err.Error() + assert.NotEmpty(t, msg) + assert.Contains(t, msg, "gateway:") + }) + } +} diff --git a/sdk/go/openshell/v1/gateway/gateway.go b/sdk/go/openshell/v1/gateway/gateway.go new file mode 100644 index 0000000000..1d643c8ecb --- /dev/null +++ b/sdk/go/openshell/v1/gateway/gateway.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// NewClient creates a fully wired SDK client from an on-disk gateway +// configuration. If name is empty, the active gateway (set via +// `openshell gateway use`) is used. +// +// The function resolves the gateway directory, parses metadata.json, +// loads tokens lazily, maps the auth mode to an SDK auth provider, and +// applies any ClientOptions before delegating to [v1.NewClient]. +// +// NewClient is safe for concurrent use from multiple goroutines. +func NewClient(name string, opts ...ClientOption) (*v1.Client, error) { + cfg, err := loadConfigInternal(name) + if err != nil { + return nil, err + } + + // Apply caller options. + cc := &clientConfig{} + for _, o := range opts { + o(cc) + } + + // Resolve auth provider: caller override takes precedence. + auth := cc.auth + if auth == nil { + auth, err = resolveAuthProvider(cfg) + if err != nil { + return nil, err + } + } + + // Build the SDK Config. + sdkCfg := types.Config{ + Address: cfg.Endpoint, + Auth: auth, + } + + // Apply TLS: caller override or auth-mode defaults. + if cc.tls != nil { + sdkCfg.TLS = cc.tls + } else if cfg.AuthMode == AuthModePlaintext { + sdkCfg.TLS = &types.TLSConfig{Insecure: true} + } + + return v1.NewClient(sdkCfg) +} + +// LoadConfig reads and parses a gateway's on-disk configuration without +// creating a client connection. If name is empty, the active gateway is +// used. +// +// The returned [Config] is an immutable snapshot; changes to the on-disk +// files after this call are not reflected. +// +// LoadConfig is safe for concurrent use from multiple goroutines. +func LoadConfig(name string) (*Config, error) { + return loadConfigInternal(name) +} + +// loadConfigInternal resolves the gateway name (including active gateway +// fallback), finds the config directory, and parses metadata.json. This +// shared implementation is used by both NewClient and LoadConfig. +func loadConfigInternal(name string) (*Config, error) { + // If name is empty, resolve the active gateway. + if name == "" { + activeName, err := resolveActiveGateway() + if err != nil { + return nil, err + } + name = activeName + } + + dir, source, err := resolveGatewayDir(name) + if err != nil { + return nil, err + } + + cfg, err := parseMetadata(dir) + if err != nil { + return nil, err + } + + // Override name from directory (validated) rather than metadata.json. + cfg.Name = name + cfg.Source = source + + return cfg, nil +} + +// ListGateways enumerates all available gateways from user and system +// directories. User gateways appear first. If the same name exists in +// both directories, only the user gateway is returned (user precedence). +// Returns an empty slice (not an error) when no gateways are configured. +// +// ListGateways is safe for concurrent use from multiple goroutines. +func ListGateways() ([]Info, error) { + seen := make(map[string]bool) + var result []Info + + activeName, _ := resolveActiveGateway() + + userBase, err := userConfigDir() + if err == nil { + names, listErr := listGatewayDirs(userBase) + if listErr != nil { + return nil, listErr + } + for _, name := range names { + seen[name] = true + result = append(result, Info{ + Name: name, + Active: name == activeName, + Source: SourceUser, + }) + } + } + + sysNames, listErr := listGatewayDirs(systemConfigBase) + if listErr != nil { + return nil, listErr + } + for _, name := range sysNames { + if !seen[name] { + result = append(result, Info{ + Name: name, + Active: name == activeName, + Source: SourceSystem, + }) + } + } + + return result, nil +} + +// resolveAuthProvider maps a Config's AuthMode to an SDK AuthProvider. +// Tokens are loaded lazily where possible. +func resolveAuthProvider(cfg *Config) (types.AuthProvider, error) { + switch cfg.AuthMode { + case AuthModeNone: + return v1.NoAuth(), nil + + case AuthModePlaintext: + return v1.NoAuth(), nil + + case AuthModeCloudflareJWT: + // Token loading is deferred to GetRequestMetadata so that + // NewClient succeeds even when the token file is missing. + // The error surfaces on first authentication attempt (FR-007). + return &lazyEdgeAuth{loader: &edgeTokenLoader{dir: cfg.Dir}}, nil + + case AuthModeOIDC: + src := newDiskTokenSource(cfg.Dir) + return v1.RefreshableToken(src) + + case AuthModeMTLS: + return nil, fmt.Errorf("%w: mtls is not yet supported; use WithAuth() to provide a custom auth provider", ErrUnsupportedAuthMode) + + default: + return nil, fmt.Errorf("%w: %q", ErrUnsupportedAuthMode, cfg.AuthMode) + } +} diff --git a/sdk/go/openshell/v1/gateway/gateway_test.go b/sdk/go/openshell/v1/gateway/gateway_test.go new file mode 100644 index 0000000000..ec519c29a6 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/gateway_test.go @@ -0,0 +1,462 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// --- Test helpers --- + +// setupGateway creates a gateway directory with the given metadata.json +// content under a temp XDG config dir. Returns the XDG root path. +func setupGateway(t *testing.T, name, metadataJSON string) string { + t.Helper() + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", metadataJSON) + + return tmp +} + +// setupGatewayWithTokens creates a gateway directory with metadata and +// token files. +func setupGatewayWithTokens(t *testing.T, name, metadataJSON string, tokens map[string]string) string { + t.Helper() + tmp := setupGateway(t, name, metadataJSON) + + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + for filename, content := range tokens { + writeFile(t, gwDir, filename, content) + } + + return tmp +} + +// --- T018: NewClient tests --- + +func TestNewClient_AuthModeNone(t *testing.T) { + setupGateway(t, "test-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("test-gw", WithTLS(&types.TLSConfig{Insecure: true})) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModePlaintext(t *testing.T) { + setupGateway(t, "plain-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"plaintext"}`) + + // Plaintext mode should auto-set insecure TLS. + client, err := NewClient("plain-gw") + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModeCloudflareJWT(t *testing.T) { + setupGatewayWithTokens(t, "cf-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`, + map[string]string{edgeTokenFile: "test-edge-token"}, + ) + + // StaticToken requires transport security, so use WithAuth override + // to bypass gRPC TLS requirement. Auth resolution is verified by + // TestResolveAuthProvider_CloudflareJWT. + client, err := NewClient("cf-gw", + WithAuth(&mockAuth{}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_AuthModeOIDC(t *testing.T) { + setupGatewayWithTokens(t, "oidc-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"oidc"}`, + map[string]string{oidcTokenFile: `{"access_token":"test-oidc-token"}`}, + ) + + // RefreshableToken requires transport security, so use WithAuth + // override. Auth resolution verified by TestResolveAuthProvider_OIDC. + client, err := NewClient("oidc-gw", + WithAuth(&mockAuth{}), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, err := NewClient("nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestNewClient_InvalidName(t *testing.T) { + _, err := NewClient("../escape") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +} + +func TestNewClient_MissingEdgeToken(t *testing.T) { + setupGateway(t, "no-token-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + + // Edge token loading is lazy (FR-007): NewClient succeeds even when + // the token file is missing. The error surfaces on first use via + // GetRequestMetadata, not at construction time. + _, err := NewClient("no-token-gw") + require.NoError(t, err) +} + +func TestNewClient_MissingOIDCToken(t *testing.T) { + setupGateway(t, "no-oidc-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"oidc"}`) + + _, err := NewClient("no-oidc-gw") + // OIDC uses RefreshableToken which defers the disk read to Token(), + // so NewClient should succeed. The error would come on first use. + // Let's verify it doesn't fail at construction time. + require.NoError(t, err) + assert.NoError(t, err) +} + +func TestNewClient_MTLSUnsupported(t *testing.T) { + setupGateway(t, "mtls-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"mtls"}`) + + _, err := NewClient("mtls-gw") + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "mtls") +} + +func TestNewClient_WithAuthOverride(t *testing.T) { + setupGateway(t, "override-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + + // Even though auth_mode is cloudflare_jwt and there's no edge_token, + // the WithAuth override should bypass token loading entirely. + customAuth := &mockAuth{} + client, err := NewClient("override-gw", + WithAuth(customAuth), + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_WithOptions(t *testing.T) { + setupGateway(t, "opts-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + client, err := NewClient("opts-gw", + WithTLS(&types.TLSConfig{Insecure: true}), + ) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +// --- T019: Credential leak test --- + +func TestNewClient_NoCredentialLeaks(t *testing.T) { + // Test 1: Invalid gateway with path traversal attempt. + _, err := NewClient("../../../etc/passwd") + require.Error(t, err) + assert.NotContains(t, err.Error(), "passwd") + + // Test 2: Verify error from missing edge token does not reveal + // file system details beyond the generic message. + setupGateway(t, "no-edge-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + cfg := &Config{ + AuthMode: AuthModeCloudflareJWT, + Dir: filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openshell", "gateways", "no-edge-gw"), + } + auth, authErr := resolveAuthProvider(cfg) + require.NoError(t, authErr) + _, err = auth.GetRequestMetadata(context.Background()) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + + // Test 3: Verify credential values never appear in error strings. + secretToken := "SUPER_SECRET_TOKEN_12345" + setupGatewayWithTokens(t, "leak-gw", + `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`, + map[string]string{edgeTokenFile: secretToken}, + ) + // Use resolveAuthProvider directly to test token loading. + cfg = &Config{ + Name: "leak-gw", + Endpoint: "localhost:50051", + AuthMode: AuthModeCloudflareJWT, + Dir: filepath.Join(os.Getenv("XDG_CONFIG_HOME"), "openshell", "gateways", "leak-gw"), + } + auth, err = resolveAuthProvider(cfg) + require.NoError(t, err) + + // The provider should not expose the token in its string form. + if stringer, ok := auth.(interface{ String() string }); ok { + assert.NotContains(t, stringer.String(), secretToken) + } +} + +func TestResolveAuthProvider_NoTokenLeaks(t *testing.T) { + secretToken := "CREDENTIAL_THAT_MUST_NOT_LEAK" + + // Create a gateway with a bad OIDC token. + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + gwDir := filepath.Join(tmp, "openshell", "gateways", "leak-test") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"localhost:50051","auth_mode":"cloudflare_jwt"}`) + writeFile(t, gwDir, edgeTokenFile, secretToken) + + cfg := &Config{ + Name: "leak-test", + Endpoint: "localhost:50051", + AuthMode: AuthModeCloudflareJWT, + Dir: gwDir, + } + + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + + // The auth provider should work but the token value should not + // appear in the provider's string representation (if any). + providerStr := "" + if stringer, ok := auth.(interface{ String() string }); ok { + providerStr = stringer.String() + assert.NotContains(t, providerStr, secretToken) + } + + // Verify the token IS used correctly via GetRequestMetadata. + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Contains(t, md["authorization"], secretToken) +} + +// --- Test helpers --- + +// mockAuth is a minimal AuthProvider for testing WithAuth overrides. +type mockAuth struct{} + +func (m *mockAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + return map[string]string{"authorization": "Bearer mock-token"}, nil +} + +func (m *mockAuth) RequireTransportSecurity() bool { + return false +} + +// --- LoadConfig tests (T025 placeholder, implemented here for Phase 3 coverage) --- + +func TestLoadConfig_ValidConfig(t *testing.T) { + setupGateway(t, "cfg-gw", `{"gateway_endpoint":"host:443","auth_mode":"oidc","name":"ignored"}`) + + cfg, err := LoadConfig("cfg-gw") + require.NoError(t, err) + // Name comes from directory, not metadata.json "name" field. + assert.Equal(t, "cfg-gw", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) + assert.Equal(t, AuthModeOIDC, cfg.AuthMode) + assert.Equal(t, SourceUser, cfg.Source) + assert.NotEmpty(t, cfg.Dir) +} + +func TestLoadConfig_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, err := LoadConfig("missing-gw") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestLoadConfig_FrozenSnapshot(t *testing.T) { + xdg := setupGateway(t, "snap-gw", `{"gateway_endpoint":"original:443","auth_mode":"none"}`) + + cfg, err := LoadConfig("snap-gw") + require.NoError(t, err) + assert.Equal(t, "original:443", cfg.Endpoint) + + // Modify the on-disk file. + gwDir := filepath.Join(xdg, "openshell", "gateways", "snap-gw") + writeFile(t, gwDir, "metadata.json", `{"gateway_endpoint":"modified:443","auth_mode":"none"}`) + + // The previously loaded config should be unchanged. + assert.Equal(t, "original:443", cfg.Endpoint) + + // A new load should see the change. + cfg2, err := LoadConfig("snap-gw") + require.NoError(t, err) + assert.Equal(t, "modified:443", cfg2.Endpoint) +} + +// --- resolveAuthProvider tests --- + +func TestResolveAuthProvider_None(t *testing.T) { + cfg := &Config{AuthMode: AuthModeNone} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestResolveAuthProvider_Plaintext(t *testing.T) { + cfg := &Config{AuthMode: AuthModePlaintext} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + assert.False(t, auth.RequireTransportSecurity()) +} + +func TestResolveAuthProvider_CloudflareJWT(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "cf-jwt-token") + + cfg := &Config{AuthMode: AuthModeCloudflareJWT, Dir: dir} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer cf-jwt-token", md["authorization"]) +} + +func TestResolveAuthProvider_OIDC(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token":"oidc-access-token"}`) + + cfg := &Config{AuthMode: AuthModeOIDC, Dir: dir} + auth, err := resolveAuthProvider(cfg) + require.NoError(t, err) + require.NotNil(t, auth) + + md, err := auth.GetRequestMetadata(context.Background()) + require.NoError(t, err) + assert.Equal(t, "Bearer oidc-access-token", md["authorization"]) +} + +func TestResolveAuthProvider_MTLS(t *testing.T) { + cfg := &Config{AuthMode: AuthModeMTLS} + _, err := resolveAuthProvider(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) + assert.Contains(t, err.Error(), "mtls") + assert.Contains(t, err.Error(), "WithAuth") +} + +func TestResolveAuthProvider_UnknownMode(t *testing.T) { + cfg := &Config{AuthMode: "alien_auth"} + _, err := resolveAuthProvider(cfg) + require.Error(t, err) + assert.ErrorIs(t, err, ErrUnsupportedAuthMode) +} + +// --- T021/T023: Active gateway tests --- + +func TestNewClient_ActiveGateway(t *testing.T) { + tmp := setupGateway(t, "active-gw", `{"gateway_endpoint":"localhost:50051","auth_mode":"none"}`) + + activeFile := filepath.Join(tmp, "openshell", "active_gateway") + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "active-gw") + _ = activeFile + + client, err := NewClient("", WithTLS(&types.TLSConfig{Insecure: true})) + require.NoError(t, err) + require.NotNil(t, client) + assert.NoError(t, client.Close()) +} + +func TestNewClient_NoActiveGateway(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + + _, err := NewClient("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestLoadConfig_ActiveGateway(t *testing.T) { + tmp := setupGateway(t, "my-active", `{"gateway_endpoint":"host:443","auth_mode":"oidc"}`) + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "my-active") + + cfg, err := LoadConfig("") + require.NoError(t, err) + assert.Equal(t, "my-active", cfg.Name) + assert.Equal(t, "host:443", cfg.Endpoint) +} + +// --- T027: ListGateways tests --- + +func TestListGateways_MultipleGateways(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + for _, name := range []string{"prod", "staging", "dev"} { + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + } + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Len(t, gateways, 3) + + names := make(map[string]bool) + for _, gw := range gateways { + names[gw.Name] = true + assert.Equal(t, SourceUser, gw.Source) + } + assert.True(t, names["prod"]) + assert.True(t, names["staging"]) + assert.True(t, names["dev"]) +} + +func TestListGateways_EmptyDirs(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Empty(t, gateways) +} + +func TestListGateways_ActiveStatus(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + for _, name := range []string{"alpha", "beta"} { + gwDir := filepath.Join(tmp, "openshell", "gateways", name) + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + } + writeFile(t, filepath.Join(tmp, "openshell"), "active_gateway", "beta") + + gateways, err := ListGateways() + require.NoError(t, err) + assert.Len(t, gateways, 2) + + for _, gw := range gateways { + if gw.Name == "beta" { + assert.True(t, gw.Active) + } else { + assert.False(t, gw.Active) + } + } +} diff --git a/sdk/go/openshell/v1/gateway/options.go b/sdk/go/openshell/v1/gateway/options.go new file mode 100644 index 0000000000..bf5e799ac6 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/options.go @@ -0,0 +1,37 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// clientConfig holds resolved options applied after gateway config +// resolution but before the v1.Client is created. +type clientConfig struct { + tls *types.TLSConfig + auth types.AuthProvider +} + +// ClientOption configures the behavior of [NewClient]. Options are +// applied after gateway configuration is resolved but before the +// underlying SDK client is created. +type ClientOption func(*clientConfig) + +// WithTLS overrides the TLS settings derived from the gateway's auth mode. +// Use this to provide custom certificates or force insecure connections. +func WithTLS(cfg *types.TLSConfig) ClientOption { + return func(c *clientConfig) { + c.tls = cfg + } +} + +// WithAuth overrides the auth provider that would normally be resolved +// from the gateway's auth_mode. When set, the gateway package skips +// its own auth resolution and uses the provided provider directly. +func WithAuth(provider types.AuthProvider) ClientOption { + return func(c *clientConfig) { + c.auth = provider + } +} diff --git a/sdk/go/openshell/v1/gateway/paths.go b/sdk/go/openshell/v1/gateway/paths.go new file mode 100644 index 0000000000..fa0e1373c2 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/paths.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "unicode" +) + +const ( + // appName is the application directory name used in XDG paths. + appName = "openshell" + + // gatewaySubdir is the subdirectory within the app config holding + // per-gateway directories. + gatewaySubdir = "gateways" + + // activeGatewayFile is the filename that stores the active gateway name. + activeGatewayFile = "active_gateway" + + // systemConfigBase is the system-wide config directory. + systemConfigBase = "/etc/openshell" +) + +// userConfigDir returns the user-specific configuration directory for +// OpenShell, following XDG Base Directory specification: +// +// $XDG_CONFIG_HOME/openshell (if XDG_CONFIG_HOME is set) +// ~/.config/openshell (fallback) +func userConfigDir() (string, error) { + if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { + if !filepath.IsAbs(xdg) { + return "", fmt.Errorf("XDG_CONFIG_HOME must be an absolute path, got %q", xdg) + } + return filepath.Join(xdg, appName), nil + } + + home, err := os.UserHomeDir() + if err != nil { + return "", fmt.Errorf("cannot determine home directory: %w", err) + } + + return filepath.Join(home, ".config", appName), nil +} + +// systemGatewayDir returns the system-wide gateway config directory. +func systemGatewayDir() string { + return filepath.Join(systemConfigBase, gatewaySubdir) +} + +// resolveGatewayDir searches for a gateway directory by name, checking the +// user directory first, then the system directory. Returns the absolute +// directory path, the config source, or ErrGatewayNotFound. +func resolveGatewayDir(name string) (string, ConfigSource, error) { + if err := validateGatewayName(name); err != nil { + return "", "", err + } + + // Check user config dir first. + userBase, err := userConfigDir() + if err == nil { + userDir := filepath.Join(userBase, gatewaySubdir, name) + if info, statErr := os.Stat(userDir); statErr == nil && info.IsDir() { + return userDir, SourceUser, nil + } + } + + // Check system config dir. + sysDir := filepath.Join(systemGatewayDir(), name) + if info, statErr := os.Stat(sysDir); statErr == nil && info.IsDir() { + return sysDir, SourceSystem, nil + } + + return "", "", fmt.Errorf("%w: %q", ErrGatewayNotFound, name) +} + +// validateGatewayName checks that a gateway name is safe for use as a +// directory component. It rejects: +// - empty names +// - names containing path separators (/ or \) +// - names that are "." or ".." (directory traversal) +// - names containing "." (prevents hidden files and extension confusion) +// - names with characters outside ASCII alphanumerics, dashes, underscores +func validateGatewayName(name string) error { + if name == "" { + return fmt.Errorf("%w: name must not be empty", ErrInvalidGatewayName) + } + + if strings.ContainsAny(name, "/\\") { + return fmt.Errorf("%w: name must not contain path separators", ErrInvalidGatewayName) + } + + if strings.Contains(name, ".") { + return fmt.Errorf("%w: name must not contain dots", ErrInvalidGatewayName) + } + + for _, r := range name { + if !isValidNameRune(r) { + return fmt.Errorf("%w: name contains invalid character %q", ErrInvalidGatewayName, string(r)) + } + } + + return nil +} + +// resolveActiveGateway reads the active_gateway file from the user +// config directory and returns the validated gateway name. Returns +// ErrNoActiveGateway if the file is missing or empty. +func resolveActiveGateway() (string, error) { + userBase, err := userConfigDir() + if err != nil { + return "", fmt.Errorf("%w: cannot determine config directory: %v", ErrNoActiveGateway, err) + } + + path := filepath.Join(userBase, activeGatewayFile) + data, err := os.ReadFile(path) + if err != nil { + return "", fmt.Errorf("%w", ErrNoActiveGateway) + } + + name := strings.TrimSpace(string(data)) + if name == "" { + return "", fmt.Errorf("%w: active_gateway file is empty", ErrNoActiveGateway) + } + + if err := validateGatewayName(name); err != nil { + return "", fmt.Errorf("%w: active_gateway contains invalid name %q: %v", ErrNoActiveGateway, name, err) + } + + return name, nil +} + +// listGatewayDirs returns a list of gateway directories found under the +// given base path. Each entry is just the directory name (gateway name). +func listGatewayDirs(base string) ([]string, error) { + gatewaysDir := filepath.Join(base, gatewaySubdir) + entries, err := os.ReadDir(gatewaysDir) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + + var names []string + for _, e := range entries { + if e.IsDir() && validateGatewayName(e.Name()) == nil { + names = append(names, e.Name()) + } + } + return names, nil +} + +// isValidNameRune returns true if the rune is an ASCII letter, digit, +// dash, or underscore. +func isValidNameRune(r rune) bool { + if r > unicode.MaxASCII { + return false + } + return (r >= 'a' && r <= 'z') || + (r >= 'A' && r <= 'Z') || + (r >= '0' && r <= '9') || + r == '-' || r == '_' +} diff --git a/sdk/go/openshell/v1/gateway/paths_test.go b/sdk/go/openshell/v1/gateway/paths_test.go new file mode 100644 index 0000000000..547bd3fc3c --- /dev/null +++ b/sdk/go/openshell/v1/gateway/paths_test.go @@ -0,0 +1,217 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T007: XDG resolution tests --- + +func TestUserConfigDir_XDGSet(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + dir, err := userConfigDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(tmp, "openshell"), dir) +} + +func TestUserConfigDir_XDGUnset(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", "") + + dir, err := userConfigDir() + require.NoError(t, err) + + home, err := os.UserHomeDir() + require.NoError(t, err) + assert.Equal(t, filepath.Join(home, ".config", "openshell"), dir) +} + +func TestSystemGatewayDir(t *testing.T) { + dir := systemGatewayDir() + assert.Equal(t, "/etc/openshell/gateways", dir) +} + +func TestResolveGatewayDir_UserDir(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + // Create a gateway directory in user config. + gwDir := filepath.Join(tmp, "openshell", "gateways", "prod") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + + dir, source, err := resolveGatewayDir("prod") + require.NoError(t, err) + assert.Equal(t, gwDir, dir) + assert.Equal(t, SourceUser, source) +} + +func TestResolveGatewayDir_NotFound(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + _, _, err := resolveGatewayDir("nonexistent") + require.Error(t, err) + assert.ErrorIs(t, err, ErrGatewayNotFound) +} + +func TestResolveGatewayDir_InvalidName(t *testing.T) { + _, _, err := resolveGatewayDir("../etc") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) +} + +func TestResolveGatewayDir_UserPrecedenceOverSystem(t *testing.T) { + // This test verifies the search order: user dir is checked before + // system dir. We can only test the user dir path since we cannot + // write to /etc in tests. The logic is verified by the successful + // user dir resolution above. + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + + gwDir := filepath.Join(tmp, "openshell", "gateways", "shared") + require.NoError(t, os.MkdirAll(gwDir, 0o755)) + + dir, source, err := resolveGatewayDir("shared") + require.NoError(t, err) + assert.Equal(t, gwDir, dir) + assert.Equal(t, SourceUser, source) +} + +// --- T008: Name validation tests --- + +func TestValidateGatewayName_ValidNames(t *testing.T) { + validNames := []string{ + "prod", + "staging", + "my-gateway", + "gateway_1", + "PROD", + "a", + "test-gateway-01", + "A_B_C", + } + + for _, name := range validNames { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + assert.NoError(t, err, "expected %q to be valid", name) + }) + } +} + +func TestValidateGatewayName_Empty(t *testing.T) { + err := validateGatewayName("") + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + assert.Contains(t, err.Error(), "empty") +} + +func TestValidateGatewayName_PathSeparators(t *testing.T) { + cases := []string{ + "../etc", + "foo/bar", + "foo\\bar", + "/absolute", + } + + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +func TestValidateGatewayName_Dots(t *testing.T) { + cases := []string{ + ".", + "..", + ".hidden", + "foo.bar", + "config.json", + } + + for _, name := range cases { + t.Run(name, func(t *testing.T) { + err := validateGatewayName(name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} + +// --- T021: Active gateway resolution tests --- + +func TestResolveActiveGateway_ValidName(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte("my-gateway"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "my-gateway", name) +} + +func TestResolveActiveGateway_WhitespaceHandling(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" my-gateway \n"), 0o644)) + + name, err := resolveActiveGateway() + require.NoError(t, err) + assert.Equal(t, "my-gateway", name) +} + +func TestResolveActiveGateway_FileMissing(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + + _, err := resolveActiveGateway() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestResolveActiveGateway_EmptyFile(t *testing.T) { + tmp := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", tmp) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "openshell"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "openshell", "active_gateway"), []byte(" \n"), 0o644)) + + _, err := resolveActiveGateway() + require.Error(t, err) + assert.ErrorIs(t, err, ErrNoActiveGateway) +} + +func TestValidateGatewayName_SpecialCharacters(t *testing.T) { + cases := []struct { + name string + desc string + }{ + {"hello world", "space"}, + {"foo@bar", "at sign"}, + {"foo#bar", "hash"}, + {"café", "non-ASCII"}, + {"日本語", "unicode"}, + {"foo bar", "tab (space)"}, + } + + for _, tc := range cases { + t.Run(tc.desc, func(t *testing.T) { + err := validateGatewayName(tc.name) + require.Error(t, err) + assert.ErrorIs(t, err, ErrInvalidGatewayName) + }) + } +} diff --git a/sdk/go/openshell/v1/gateway/token.go b/sdk/go/openshell/v1/gateway/token.go new file mode 100644 index 0000000000..afcf664d97 --- /dev/null +++ b/sdk/go/openshell/v1/gateway/token.go @@ -0,0 +1,176 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +const ( + // edgeTokenFile is the primary edge token filename. + edgeTokenFile = "edge_token" + + // cfTokenFile is the legacy Cloudflare token filename, used as + // fallback when edge_token does not exist. + cfTokenFile = "cf_token" + + // oidcTokenFile is the OIDC token bundle filename. + oidcTokenFile = "oidc_token.json" +) + +// edgeTokenLoader provides lazy, thread-safe loading of the edge token +// from disk. Successful loads are cached; failures are retried so a token +// created after client construction can be picked up without a restart. +type edgeTokenLoader struct { + dir string + mu sync.Mutex + token string +} + +// load returns the edge token string, reading it from disk on the first +// call. It tries edge_token first, falling back to cf_token for legacy +// compatibility. Only a successful result is cached. +func (l *edgeTokenLoader) load() (string, error) { + l.mu.Lock() + defer l.mu.Unlock() + if l.token != "" { + return l.token, nil + } + token, err := readEdgeToken(l.dir) + if err != nil { + return "", err + } + l.token = token + return token, nil +} + +// readEdgeToken reads the edge token from the given directory. It tries +// edge_token first, then cf_token as a legacy fallback. The file content +// is trimmed of surrounding whitespace. +func readEdgeToken(dir string) (string, error) { + // Try primary edge_token file. + primary := filepath.Join(dir, edgeTokenFile) + data, err := os.ReadFile(primary) + if err == nil { + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("%w: edge_token file is empty", ErrTokenLoad) + } + return token, nil + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("%w: cannot read %s: %v", ErrTokenLoad, edgeTokenFile, err) + } + + // Fallback to legacy cf_token file (only when edge_token is absent). + legacy := filepath.Join(dir, cfTokenFile) + data, err = os.ReadFile(legacy) + if err != nil { + return "", fmt.Errorf("%w: neither edge_token nor cf_token found in gateway directory", ErrTokenLoad) + } + + token := strings.TrimSpace(string(data)) + if token == "" { + return "", fmt.Errorf("%w: cf_token file is empty", ErrTokenLoad) + } + + return token, nil +} + +// oidcBundle is the on-disk representation of oidc_token.json. +type oidcBundle struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry string `json:"expiry"` + ExpiresIn int64 `json:"expires_in"` +} + +// diskTokenSource implements oauth2.TokenSource by reading oidc_token.json +// from disk on every Token() call. This allows the source to pick up +// tokens refreshed by the Rust CLI without process restart. +type diskTokenSource struct { + dir string +} + +// newDiskTokenSource returns an oauth2.TokenSource that reads +// oidc_token.json from the given gateway directory on each Token() call. +func newDiskTokenSource(dir string) oauth2.TokenSource { + return &diskTokenSource{dir: dir} +} + +// Token reads and parses oidc_token.json, returning an oauth2.Token. +// The file is read on every call to pick up CLI-refreshed tokens. +func (d *diskTokenSource) Token() (*oauth2.Token, error) { + path := filepath.Join(d.dir, oidcTokenFile) + + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("%w: cannot read %s: %v", ErrTokenLoad, oidcTokenFile, err) + } + + var bundle oidcBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s", ErrTokenLoad, oidcTokenFile) + } + + if bundle.AccessToken == "" { + return nil, fmt.Errorf("%w: missing access_token in %s", ErrTokenLoad, oidcTokenFile) + } + + tok := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + TokenType: "Bearer", + } + + // Parse expiry from the absolute "expiry" field only. The "expires_in" + // field (seconds-until-expiry) cannot be used reliably because there is + // no "written_at" timestamp: interpreting it at read time would make + // stale tokens appear perpetually valid. + if bundle.Expiry != "" { + expiry, parseErr := time.Parse(time.RFC3339, bundle.Expiry) + if parseErr != nil { + return nil, fmt.Errorf("%w: invalid expiry in %s: %v", ErrTokenLoad, oidcTokenFile, parseErr) + } + tok.Expiry = expiry + } + + return tok, nil +} + +// lazyEdgeAuth implements types.AuthProvider for the cloudflare_jwt auth +// mode. Token loading is deferred to GetRequestMetadata so that +// NewClient succeeds even when the token file is missing on disk (FR-007). +// The error surfaces on the first authentication attempt. +type lazyEdgeAuth struct { + loader *edgeTokenLoader +} + +// GetRequestMetadata loads the edge token lazily and returns it as a +// Bearer authorization header. The first load is cached by the +// underlying edgeTokenLoader. +func (a *lazyEdgeAuth) GetRequestMetadata(_ context.Context, _ ...string) (map[string]string, error) { + token, err := a.loader.load() + if err != nil { + return nil, err + } + return map[string]string{ + "authorization": "Bearer " + token, + }, nil +} + +// RequireTransportSecurity returns true because Bearer tokens must not +// be sent over plaintext connections. +func (a *lazyEdgeAuth) RequireTransportSecurity() bool { + return true +} diff --git a/sdk/go/openshell/v1/gateway/token_test.go b/sdk/go/openshell/v1/gateway/token_test.go new file mode 100644 index 0000000000..82e7e2feef --- /dev/null +++ b/sdk/go/openshell/v1/gateway/token_test.go @@ -0,0 +1,252 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package gateway + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T014: Edge token loading tests --- + +func TestReadEdgeToken_PrimaryFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "my-edge-token-123") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "my-edge-token-123", token) +} + +func TestReadEdgeToken_PrimaryFileWithWhitespace(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, " token-with-whitespace \n") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "token-with-whitespace", token) +} + +func TestReadEdgeToken_CfTokenFallback(t *testing.T) { + dir := t.TempDir() + // No edge_token file, only cf_token. + writeFile(t, dir, cfTokenFile, "legacy-cf-token") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "legacy-cf-token", token) +} + +func TestReadEdgeToken_PrimaryTakesPrecedence(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "primary-token") + writeFile(t, dir, cfTokenFile, "legacy-token") + + token, err := readEdgeToken(dir) + require.NoError(t, err) + assert.Equal(t, "primary-token", token) +} + +func TestReadEdgeToken_MissingBothFiles(t *testing.T) { + dir := t.TempDir() + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "neither edge_token nor cf_token") +} + +func TestReadEdgeToken_EmptyPrimaryFile(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, " \n") + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "empty") +} + +func TestReadEdgeToken_EmptyFallbackFile(t *testing.T) { + dir := t.TempDir() + // No edge_token, only empty cf_token. + writeFile(t, dir, cfTokenFile, "") + + _, err := readEdgeToken(dir) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "empty") +} + +func TestEdgeTokenLoader_Lazy(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, edgeTokenFile, "lazy-token") + + loader := &edgeTokenLoader{dir: dir} + + // First call reads from disk. + token1, err := loader.load() + require.NoError(t, err) + assert.Equal(t, "lazy-token", token1) + + // Remove the file. Second call should return cached value. + require.NoError(t, os.Remove(filepath.Join(dir, edgeTokenFile))) + + token2, err := loader.load() + require.NoError(t, err) + assert.Equal(t, "lazy-token", token2, "should return cached token") +} + +func TestEdgeTokenLoader_LazyError(t *testing.T) { + dir := t.TempDir() + // No token files exist. + + loader := &edgeTokenLoader{dir: dir} + + // First call fails. + _, err1 := loader.load() + require.Error(t, err1) + + // Write the file now. A transient load failure must not be cached. + writeFile(t, dir, edgeTokenFile, "late-token") + + token, err2 := loader.load() + require.NoError(t, err2) + assert.Equal(t, "late-token", token) +} + +// --- T015: diskTokenSource tests --- + +func TestDiskTokenSource_ValidBundle(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(time.Hour).UTC().Format(time.RFC3339) + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "expiry": "`+expiry+`" + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.Equal(t, "Bearer", tok.TokenType) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestDiskTokenSource_ExpiresInIgnored(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-789", + "expires_in": 3600 + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "access-789", tok.AccessToken) + // expires_in without an absolute expiry field is ignored because + // there is no written_at timestamp to compute absolute expiry from. + assert.True(t, tok.Expiry.IsZero(), "expiry should be zero when only expires_in is present") +} + +func TestDiskTokenSource_ExpiryPrecedence(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(2 * time.Hour).UTC().Format(time.RFC3339) + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "access-abc", + "expiry": "`+expiry+`", + "expires_in": 60 + }`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + // "expiry" field takes precedence over "expires_in". + assert.WithinDuration(t, time.Now().Add(2*time.Hour), tok.Expiry, 5*time.Second) +} + +func TestDiskTokenSource_NoExpiry(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token": "no-expiry-token"}`) + + src := newDiskTokenSource(dir) + tok, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "no-expiry-token", tok.AccessToken) + assert.True(t, tok.Expiry.IsZero(), "should have zero expiry when not set") +} + +func TestDiskTokenSource_MissingFile(t *testing.T) { + dir := t.TempDir() + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) +} + +func TestDiskTokenSource_MalformedJSON(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{not valid json}`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "invalid JSON") +} + +func TestDiskTokenSource_MissingAccessToken(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"refresh_token": "only-refresh"}`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) + assert.Contains(t, err.Error(), "missing access_token") +} + +func TestDiskTokenSource_ReReadsOnEachCall(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{"access_token": "token-v1"}`) + + src := newDiskTokenSource(dir) + tok1, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-v1", tok1.AccessToken) + + // Update the file. Next call should read the new value. + writeFile(t, dir, oidcTokenFile, `{"access_token": "token-v2"}`) + + tok2, err := src.Token() + require.NoError(t, err) + assert.Equal(t, "token-v2", tok2.AccessToken) +} + +func TestDiskTokenSource_InvalidExpiryFormat(t *testing.T) { + dir := t.TempDir() + writeFile(t, dir, oidcTokenFile, `{ + "access_token": "token-xyz", + "expiry": "not-a-date" + }`) + + src := newDiskTokenSource(dir) + _, err := src.Token() + require.Error(t, err) + assert.ErrorIs(t, err, ErrTokenLoad) +} + +// writeFile is a test helper that writes a file with the given content. +func writeFile(t *testing.T, dir, name, content string) { + t.Helper() + err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644) + require.NoError(t, err) +} diff --git a/sdk/go/openshell/v1/health.go b/sdk/go/openshell/v1/health.go index c5e62eaa32..4230ca6cb2 100644 --- a/sdk/go/openshell/v1/health.go +++ b/sdk/go/openshell/v1/health.go @@ -12,7 +12,29 @@ import ( // HealthResult holds the result of a health check. type HealthResult = types.HealthResult -// HealthInterface defines health check operations. +// GatewayInfo holds operational metadata about the gateway. +type GatewayInfo = types.GatewayInfo + +// ComputeDriverInfo describes a compute backend available on the gateway. +type ComputeDriverInfo = types.ComputeDriverInfo + +// ServiceStatus describes the health state of the gateway. +type ServiceStatus = types.ServiceStatus + +// ServiceStatus constants. +const ( + ServiceStatusHealthy = types.ServiceStatusHealthy + ServiceStatusDegraded = types.ServiceStatusDegraded + ServiceStatusUnhealthy = types.ServiceStatusUnhealthy + ServiceStatusUnknown = types.ServiceStatusUnknown +) + +// CurrentUser holds the authenticated caller's identity. +type CurrentUser = types.CurrentUser + +// HealthInterface defines health check and gateway info operations. type HealthInterface interface { Check(ctx context.Context) (*HealthResult, error) + GetGatewayInfo(ctx context.Context) (*GatewayInfo, error) + GetCurrentUser(ctx context.Context) (*CurrentUser, error) } diff --git a/sdk/go/openshell/v1/health_client.go b/sdk/go/openshell/v1/health_client.go new file mode 100644 index 0000000000..87c958a0dc --- /dev/null +++ b/sdk/go/openshell/v1/health_client.go @@ -0,0 +1,48 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type healthClient struct { + client pb.OpenShellClient +} + +func newHealthClient(conn grpc.ClientConnInterface) *healthClient { + return &healthClient{client: pb.NewOpenShellClient(conn)} +} + +func (h *healthClient) Check(ctx context.Context) (*HealthResult, error) { + resp, err := h.client.Health(ctx, &pb.HealthRequest{}) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + return &HealthResult{ + Healthy: resp.GetStatus() == pb.ServiceStatus_SERVICE_STATUS_HEALTHY, + Version: resp.GetVersion(), + }, nil +} + +func (h *healthClient) GetGatewayInfo(ctx context.Context) (*GatewayInfo, error) { + resp, err := h.client.GetGatewayInfo(ctx, &pb.GetGatewayInfoRequest{}) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.GatewayInfoFromProto(resp), nil +} + +func (h *healthClient) GetCurrentUser(ctx context.Context) (*CurrentUser, error) { + resp, err := h.client.GetCurrentUser(ctx, &pb.GetCurrentUserRequest{}) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.CurrentUserFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/health_client_test.go b/sdk/go/openshell/v1/health_client_test.go new file mode 100644 index 0000000000..688639120a --- /dev/null +++ b/sdk/go/openshell/v1/health_client_test.go @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +const bufSize = 1024 * 1024 + +type mockHealthServer struct { + pb.UnimplementedOpenShellServer + status pb.ServiceStatus + version string + err error + gatewayInfoResp *pb.GetGatewayInfoResponse + currentUserResp *pb.GetCurrentUserResponse + gatewayInfoErr error + currentUserErr error +} + +func (s *mockHealthServer) Health(_ context.Context, _ *pb.HealthRequest) (*pb.HealthResponse, error) { + if s.err != nil { + return nil, s.err + } + return &pb.HealthResponse{ + Status: s.status, + Version: s.version, + }, nil +} + +func (s *mockHealthServer) GetGatewayInfo(_ context.Context, _ *pb.GetGatewayInfoRequest) (*pb.GetGatewayInfoResponse, error) { + if s.gatewayInfoErr != nil { + return nil, s.gatewayInfoErr + } + return s.gatewayInfoResp, nil +} + +func (s *mockHealthServer) GetCurrentUser(_ context.Context, _ *pb.GetCurrentUserRequest) (*pb.GetCurrentUserResponse, error) { + if s.currentUserErr != nil { + return nil, s.currentUserErr + } + return s.currentUserResp, nil +} + +func newMockHealthServer(s pb.ServiceStatus, version string, err error) (*grpc.ClientConn, func()) { + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, &mockHealthServer{status: s, version: version, err: err}) + + go func() { _ = srv.Serve(lis) }() + + conn, err2 := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + + if err2 != nil { + srv.Stop() + panic("grpc.NewClient failed: " + err2.Error()) + } + + return conn, func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestHealthCheck_Success(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_HEALTHY, "1.2.3", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.True(t, result.Healthy) + assert.Equal(t, "1.2.3", result.Version) +} + +func TestHealthCheck_Degraded(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_DEGRADED, "2.0.0", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "2.0.0", result.Version) +} + +func TestHealthCheck_Unhealthy(t *testing.T) { + conn, cleanup := newMockHealthServer(pb.ServiceStatus_SERVICE_STATUS_UNHEALTHY, "3.0.0", nil) + defer cleanup() + + h := newHealthClient(conn) + result, err := h.Check(context.Background()) + + require.NoError(t, err) + assert.False(t, result.Healthy) + assert.Equal(t, "3.0.0", result.Version) +} + +func TestHealthCheck_Unavailable(t *testing.T) { + conn, cleanup := newMockHealthServer(0, "", status.Error(codes.Unavailable, "service down")) + defer cleanup() + + h := newHealthClient(conn) + _, err := h.Check(context.Background()) + + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +func newMockGatewayInfoServer(resp *pb.GetGatewayInfoResponse, err error) (*grpc.ClientConn, func()) { + mock := &mockHealthServer{ + gatewayInfoResp: resp, + gatewayInfoErr: err, + status: pb.ServiceStatus_SERVICE_STATUS_HEALTHY, + version: "1.0.0", + } + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + + go func() { _ = srv.Serve(lis) }() + + conn, err2 := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err2 != nil { + srv.Stop() + panic("grpc.NewClient failed: " + err2.Error()) + } + + return conn, func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestGetGatewayInfo_Success(t *testing.T) { + resp := &pb.GetGatewayInfoResponse{ + Status: pb.ServiceStatus_SERVICE_STATUS_HEALTHY, + GatewayVersion: "1.5.0", + ComputeDrivers: []*pb.ComputeDriverInfo{ + { + Name: "k8s", + Capabilities: &pb.ComputeDriverCapabilities{ + DriverName: "kubernetes", + DriverVersion: "2.1.0", + }, + }, + }, + } + conn, cleanup := newMockGatewayInfoServer(resp, nil) + defer cleanup() + + h := newHealthClient(conn) + info, err := h.GetGatewayInfo(context.Background()) + + require.NoError(t, err) + require.NotNil(t, info) + assert.Equal(t, ServiceStatusHealthy, info.Status) + assert.Equal(t, "1.5.0", info.Version) + require.Len(t, info.ComputeDrivers, 1) + assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) + assert.Equal(t, "kubernetes", info.ComputeDrivers[0].DriverName) + assert.Equal(t, "2.1.0", info.ComputeDrivers[0].DriverVersion) +} + +func TestGetGatewayInfo_Error(t *testing.T) { + conn, cleanup := newMockGatewayInfoServer(nil, status.Error(codes.PermissionDenied, "not admin")) + defer cleanup() + + h := newHealthClient(conn) + _, err := h.GetGatewayInfo(context.Background()) + + require.Error(t, err) + assert.True(t, IsPermissionDenied(err)) +} + +func newMockCurrentUserServer(resp *pb.GetCurrentUserResponse, err error) (*grpc.ClientConn, func()) { + mock := &mockHealthServer{ + currentUserResp: resp, + currentUserErr: err, + status: pb.ServiceStatus_SERVICE_STATUS_HEALTHY, + version: "1.0.0", + } + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + + go func() { _ = srv.Serve(lis) }() + + conn, err2 := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err2 != nil { + srv.Stop() + panic("grpc.NewClient failed: " + err2.Error()) + } + + return conn, func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestGetCurrentUser_Success(t *testing.T) { + resp := &pb.GetCurrentUserResponse{ + Subject: "user-123", + DisplayName: "Test User", + Roles: []string{"admin"}, + Scopes: []string{"read", "write"}, + IdentityProvider: "oidc-provider", + } + conn, cleanup := newMockCurrentUserServer(resp, nil) + defer cleanup() + + h := newHealthClient(conn) + user, err := h.GetCurrentUser(context.Background()) + + require.NoError(t, err) + require.NotNil(t, user) + assert.Equal(t, "user-123", user.Subject) + assert.Equal(t, "Test User", user.DisplayName) + assert.Equal(t, []string{"admin"}, user.Roles) + assert.Equal(t, []string{"read", "write"}, user.Scopes) + assert.Equal(t, "oidc-provider", user.IdentityProvider) +} + +func TestGetCurrentUser_Unauthenticated(t *testing.T) { + conn, cleanup := newMockCurrentUserServer(nil, status.Error(codes.Unauthenticated, "invalid token")) + defer cleanup() + + h := newHealthClient(conn) + _, err := h.GetCurrentUser(context.Background()) + + require.Error(t, err) + assert.True(t, IsUnauthenticated(err)) +} diff --git a/sdk/go/openshell/v1/inference.go b/sdk/go/openshell/v1/inference.go new file mode 100644 index 0000000000..7dfe98ae72 --- /dev/null +++ b/sdk/go/openshell/v1/inference.go @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// InferenceRouteConfig holds parameters for setting an inference route. +type InferenceRouteConfig = types.InferenceRouteConfig + +// InferenceRoute represents a configured inference route as returned by the +// gateway. +type InferenceRoute = types.InferenceRoute + +// ValidatedEndpoint represents an endpoint probed during route validation. +type ValidatedEndpoint = types.ValidatedEndpoint + +// InferenceInterface defines inference route management operations. +// Accessed via client.Inference(). +type InferenceInterface interface { + // SetRoute configures an inference route for a workspace. + // Returns ErrorInvalidArgument if workspace, providerName, or modelID is empty. + SetRoute(ctx context.Context, workspace string, config *InferenceRouteConfig) (*InferenceRoute, error) + + // GetRoute retrieves the inference route for a workspace by route name. + // Returns ErrorInvalidArgument if workspace is empty. + // Returns ErrorNotFound if no route exists for the given name. + GetRoute(ctx context.Context, workspace, routeName string) (*InferenceRoute, error) + + // DeleteRoute removes an inference route from a workspace. + // Returns ErrorInvalidArgument if workspace is empty. + // Idempotent: deleting a non-existent route is not an error. + DeleteRoute(ctx context.Context, workspace, routeName string) error +} diff --git a/sdk/go/openshell/v1/inference_client.go b/sdk/go/openshell/v1/inference_client.go new file mode 100644 index 0000000000..821aca35ea --- /dev/null +++ b/sdk/go/openshell/v1/inference_client.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/inferencev1" + "google.golang.org/grpc" +) + +type inferenceClient struct { + client pb.InferenceClient +} + +func newInferenceClient(conn grpc.ClientConnInterface) *inferenceClient { + return &inferenceClient{client: pb.NewInferenceClient(conn)} +} + +func (c *inferenceClient) SetRoute(ctx context.Context, workspace string, config *InferenceRouteConfig) (*InferenceRoute, error) { + if workspace == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "workspace must not be empty"} + } + if config == nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "config must not be nil"} + } + if config.ProviderName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "provider name must not be empty"} + } + if config.ModelID == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "model ID must not be empty"} + } + + req := converter.InferenceRouteConfigToProto(workspace, config) + resp, err := c.client.SetInferenceRoute(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.InferenceRouteFromSetResponse(resp), nil +} + +func (c *inferenceClient) GetRoute(ctx context.Context, workspace, routeName string) (*InferenceRoute, error) { + if workspace == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "workspace must not be empty"} + } + + resp, err := c.client.GetInferenceRoute(ctx, &pb.GetInferenceRouteRequest{ + Workspace: workspace, + RouteName: routeName, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.InferenceRouteFromGetResponse(resp), nil +} + +func (c *inferenceClient) DeleteRoute(ctx context.Context, workspace, routeName string) error { + if workspace == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "workspace must not be empty"} + } + + _, err := c.client.DeleteInferenceRoute(ctx, &pb.DeleteInferenceRouteRequest{ + Workspace: workspace, + RouteName: routeName, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} diff --git a/sdk/go/openshell/v1/inference_client_test.go b/sdk/go/openshell/v1/inference_client_test.go new file mode 100644 index 0000000000..a75e6e97fe --- /dev/null +++ b/sdk/go/openshell/v1/inference_client_test.go @@ -0,0 +1,405 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/inferencev1" + "github.com/stretchr/testify/assert" + "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" +) + +type mockInferenceServer struct { + pb.UnimplementedInferenceServer + + setResp *pb.SetInferenceRouteResponse + getResp *pb.GetInferenceRouteResponse + deleteResp *pb.DeleteInferenceRouteResponse + err error + + lastSetReq *pb.SetInferenceRouteRequest + lastGetReq *pb.GetInferenceRouteRequest + lastDeleteReq *pb.DeleteInferenceRouteRequest +} + +func (s *mockInferenceServer) SetInferenceRoute(_ context.Context, req *pb.SetInferenceRouteRequest) (*pb.SetInferenceRouteResponse, error) { + s.lastSetReq = req + if s.err != nil { + return nil, s.err + } + return s.setResp, nil +} + +func (s *mockInferenceServer) GetInferenceRoute(_ context.Context, req *pb.GetInferenceRouteRequest) (*pb.GetInferenceRouteResponse, error) { + s.lastGetReq = req + if s.err != nil { + return nil, s.err + } + return s.getResp, nil +} + +func (s *mockInferenceServer) DeleteInferenceRoute(_ context.Context, req *pb.DeleteInferenceRouteRequest) (*pb.DeleteInferenceRouteResponse, error) { + s.lastDeleteReq = req + if s.err != nil { + return nil, s.err + } + return s.deleteResp, nil +} + +func newMockInferenceServer(mock *mockInferenceServer) (*grpc.ClientConn, func()) { + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterInferenceServer(srv, mock) + + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + srv.Stop() + panic("grpc.NewClient failed: " + err.Error()) + } + + return conn, func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- SetRoute tests --- + +func TestSetRoute_Success(t *testing.T) { + mock := &mockInferenceServer{ + setResp: &pb.SetInferenceRouteResponse{ + ProviderName: "openai", + ModelId: "gpt-4", + Version: 1, + RouteName: "my-route", + ValidationPerformed: true, + ValidatedEndpoints: []*pb.ValidatedEndpoint{ + {Url: "https://api.openai.com/v1", Protocol: "openai"}, + }, + TimeoutSecs: 120, + Workspace: "team-alpha", + }, + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + route, err := ic.SetRoute(context.Background(), "team-alpha", &InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "my-route", + NoVerify: false, + TimeoutSecs: 120, + }) + + require.NoError(t, err) + require.NotNil(t, route) + assert.Equal(t, "openai", route.ProviderName) + assert.Equal(t, "gpt-4", route.ModelID) + assert.Equal(t, uint64(1), route.Version) + assert.Equal(t, "my-route", route.RouteName) + assert.True(t, route.ValidationPerformed) + require.Len(t, route.ValidatedEndpoints, 1) + assert.Equal(t, "https://api.openai.com/v1", route.ValidatedEndpoints[0].URL) + assert.Equal(t, "openai", route.ValidatedEndpoints[0].Protocol) + assert.Equal(t, uint64(120), route.TimeoutSecs) + assert.Equal(t, "team-alpha", route.Workspace) + + // Verify the proto request was correctly constructed. + assert.Equal(t, "openai", mock.lastSetReq.GetProviderName()) + assert.Equal(t, "gpt-4", mock.lastSetReq.GetModelId()) + assert.Equal(t, "my-route", mock.lastSetReq.GetRouteName()) + assert.Equal(t, "team-alpha", mock.lastSetReq.GetWorkspace()) + assert.Equal(t, uint64(120), mock.lastSetReq.GetTimeoutSecs()) +} + +func TestSetRoute_EmptyWorkspace(t *testing.T) { + mock := &mockInferenceServer{} + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + _, err := ic.SetRoute(context.Background(), "", &InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + }) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSetRoute_NilConfig(t *testing.T) { + mock := &mockInferenceServer{} + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + _, err := ic.SetRoute(context.Background(), "ws", nil) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSetRoute_EmptyProviderName(t *testing.T) { + mock := &mockInferenceServer{} + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + _, err := ic.SetRoute(context.Background(), "ws", &InferenceRouteConfig{ + ProviderName: "", + ModelID: "gpt-4", + }) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSetRoute_EmptyModelID(t *testing.T) { + mock := &mockInferenceServer{} + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + _, err := ic.SetRoute(context.Background(), "ws", &InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "", + }) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSetRoute_EmptyRouteName(t *testing.T) { + mock := &mockInferenceServer{ + setResp: &pb.SetInferenceRouteResponse{ + ProviderName: "openai", + ModelId: "gpt-4", + Version: 1, + RouteName: "", + Workspace: "ws", + }, + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + route, err := ic.SetRoute(context.Background(), "ws", &InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "", + }) + + require.NoError(t, err) + require.NotNil(t, route) + assert.Empty(t, route.RouteName) +} + +func TestSetRoute_PermissionDenied(t *testing.T) { + mock := &mockInferenceServer{ + err: status.Error(codes.PermissionDenied, "workspace admin required"), + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + _, err := ic.SetRoute(context.Background(), "ws", &InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + }) + + require.Error(t, err) + assert.True(t, IsPermissionDenied(err)) +} + +func TestSetRoute_NoVerify(t *testing.T) { + mock := &mockInferenceServer{ + setResp: &pb.SetInferenceRouteResponse{ + ProviderName: "openai", + ModelId: "gpt-4", + Version: 1, + Workspace: "ws", + }, + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + _, err := ic.SetRoute(context.Background(), "ws", &InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + NoVerify: true, + }) + + require.NoError(t, err) + assert.True(t, mock.lastSetReq.GetNoVerify()) +} + +// --- GetRoute tests --- + +func TestGetRoute_Success(t *testing.T) { + mock := &mockInferenceServer{ + getResp: &pb.GetInferenceRouteResponse{ + ProviderName: "vertex", + ModelId: "gemini-pro", + Version: 3, + RouteName: "default", + TimeoutSecs: 60, + Workspace: "prod", + }, + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + route, err := ic.GetRoute(context.Background(), "prod", "default") + + require.NoError(t, err) + require.NotNil(t, route) + assert.Equal(t, "vertex", route.ProviderName) + assert.Equal(t, "gemini-pro", route.ModelID) + assert.Equal(t, uint64(3), route.Version) + assert.Equal(t, "default", route.RouteName) + assert.Equal(t, uint64(60), route.TimeoutSecs) + assert.Equal(t, "prod", route.Workspace) + assert.False(t, route.ValidationPerformed) + assert.Nil(t, route.ValidatedEndpoints) + + assert.Equal(t, "prod", mock.lastGetReq.GetWorkspace()) + assert.Equal(t, "default", mock.lastGetReq.GetRouteName()) +} + +func TestGetRoute_EmptyWorkspace(t *testing.T) { + mock := &mockInferenceServer{} + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + _, err := ic.GetRoute(context.Background(), "", "my-route") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestGetRoute_NotFound(t *testing.T) { + mock := &mockInferenceServer{ + err: status.Error(codes.NotFound, "route not found"), + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + _, err := ic.GetRoute(context.Background(), "ws", "missing-route") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestGetRoute_EmptyRouteName(t *testing.T) { + mock := &mockInferenceServer{ + getResp: &pb.GetInferenceRouteResponse{ + ProviderName: "openai", + ModelId: "gpt-4", + Version: 1, + RouteName: "", + Workspace: "ws", + }, + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + route, err := ic.GetRoute(context.Background(), "ws", "") + + require.NoError(t, err) + require.NotNil(t, route) + assert.Empty(t, route.RouteName) + assert.Empty(t, mock.lastGetReq.GetRouteName()) +} + +// --- DeleteRoute tests --- + +func TestDeleteRoute_Success(t *testing.T) { + mock := &mockInferenceServer{ + deleteResp: &pb.DeleteInferenceRouteResponse{Deleted: true}, + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + err := ic.DeleteRoute(context.Background(), "ws", "my-route") + + require.NoError(t, err) + assert.Equal(t, "ws", mock.lastDeleteReq.GetWorkspace()) + assert.Equal(t, "my-route", mock.lastDeleteReq.GetRouteName()) +} + +func TestDeleteRoute_EmptyWorkspace(t *testing.T) { + mock := &mockInferenceServer{} + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + err := ic.DeleteRoute(context.Background(), "", "my-route") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestDeleteRoute_Idempotent(t *testing.T) { + // Deleting a non-existent route should succeed (gateway returns OK). + mock := &mockInferenceServer{ + deleteResp: &pb.DeleteInferenceRouteResponse{Deleted: false}, + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + err := ic.DeleteRoute(context.Background(), "ws", "nonexistent") + + require.NoError(t, err) +} + +func TestDeleteRoute_PermissionDenied(t *testing.T) { + mock := &mockInferenceServer{ + err: status.Error(codes.PermissionDenied, "workspace admin required"), + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + err := ic.DeleteRoute(context.Background(), "ws", "my-route") + + require.Error(t, err) + assert.True(t, IsPermissionDenied(err)) +} + +func TestDeleteRoute_EmptyRouteName(t *testing.T) { + mock := &mockInferenceServer{ + deleteResp: &pb.DeleteInferenceRouteResponse{Deleted: true}, + } + conn, cleanup := newMockInferenceServer(mock) + defer cleanup() + + ic := newInferenceClient(conn) + err := ic.DeleteRoute(context.Background(), "ws", "") + + require.NoError(t, err) + assert.Empty(t, mock.lastDeleteReq.GetRouteName()) +} diff --git a/sdk/go/openshell/v1/integration_test.go b/sdk/go/openshell/v1/integration_test.go index 9c8123052b..d4c0beeafc 100644 --- a/sdk/go/openshell/v1/integration_test.go +++ b/sdk/go/openshell/v1/integration_test.go @@ -7,8 +7,11 @@ package v1 import ( "context" + "errors" + "fmt" "os" "testing" + "time" "github.com/stretchr/testify/require" ) @@ -27,42 +30,44 @@ func TestIntegration_HealthCheck(t *testing.T) { client, err := NewClient(Config{Address: addr}) require.NoError(t, err) - defer client.Close() - - t.Skip("TODO: Health().Check() is a stub until PR B lands") + t.Cleanup(func() { require.NoError(t, client.Close()) }) _, err = client.Health().Check(context.Background()) require.NoError(t, err) } -func TestIntegration_ProviderLifecycle(t *testing.T) { +func TestIntegration_SandboxExecSmoke(t *testing.T) { addr := gatewayAddress(t) - client, err := NewClient(Config{Address: addr}) require.NoError(t, err) - defer client.Close() + t.Cleanup(func() { require.NoError(t, client.Close()) }) - t.Skip("TODO: implement provider create/get/list/delete integration test") -} - -func TestIntegration_SandboxLifecycle(t *testing.T) { - addr := gatewayAddress(t) + image := os.Getenv("OPENSHELL_GO_SDK_TEST_IMAGE") + if image == "" { + image = "ghcr.io/nvidia/openshell-community/sandboxes/base:latest" + } + name := fmt.Sprintf("go-smoke-%09d", time.Now().UnixNano()%1_000_000_000) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() - client, err := NewClient(Config{Address: addr}) + _, err = client.Sandboxes().Create(ctx, "default", name, &SandboxSpec{ + Template: &SandboxTemplate{Image: image}, + }, nil) require.NoError(t, err) - defer client.Close() - - t.Skip("TODO: implement sandbox create/wait-ready/delete integration test") -} - -func TestIntegration_ExecRun(t *testing.T) { - addr := gatewayAddress(t) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cleanupCancel() + require.NoError(t, client.Sandboxes().Delete(cleanupCtx, "default", name)) + }) - client, err := NewClient(Config{Address: addr}) + _, err = client.Sandboxes().WaitReady(ctx, "default", name) require.NoError(t, err) - defer client.Close() - - t.Skip("TODO: implement exec run integration test") + result, err := client.Exec().Run(ctx, "default", name, + []string{"sh", "-c", "printf openshell-go-sdk-smoke"}, ExecOptions{}) + require.NoError(t, err) + require.Equal(t, 0, result.ExitCode) + require.Equal(t, "openshell-go-sdk-smoke", string(result.Stdout)) + require.Empty(t, result.Stderr) } func TestIntegration_FileTransfer(t *testing.T) { @@ -72,5 +77,7 @@ func TestIntegration_FileTransfer(t *testing.T) { require.NoError(t, err) defer client.Close() - t.Skip("TODO: implement file upload/download integration test") + err = client.Files().Upload(context.Background(), "default", "unused", "missing", "/tmp/missing") + require.Error(t, err) + require.True(t, errors.Is(err, ErrTransportNotAvailable)) } diff --git a/sdk/go/openshell/v1/internal/converter/copy.go b/sdk/go/openshell/v1/internal/converter/copy.go index 9ab7f0f0eb..e2523a61c3 100644 --- a/sdk/go/openshell/v1/internal/converter/copy.go +++ b/sdk/go/openshell/v1/internal/converter/copy.go @@ -3,8 +3,6 @@ package converter -import "google.golang.org/protobuf/types/known/structpb" - // CopyStringMap returns a shallow copy of a string-to-string map. // Returns nil for nil input. func CopyStringMap(m map[string]string) map[string]string { @@ -50,16 +48,12 @@ func CopyByteSlice(b []byte) []byte { return c } -func structToMap(s *structpb.Struct) map[string]any { - if s == nil { - return nil - } - return s.AsMap() -} - -func mapToStruct(m map[string]any) (*structpb.Struct, error) { - if m == nil { - return nil, nil +func boolCount(flags ...bool) int { + n := 0 + for _, f := range flags { + if f { + n++ + } } - return structpb.NewStruct(m) + return n } diff --git a/sdk/go/openshell/v1/internal/converter/coverage_test.go b/sdk/go/openshell/v1/internal/converter/coverage_test.go index 17f3164f74..34cdc0e05e 100644 --- a/sdk/go/openshell/v1/internal/converter/coverage_test.go +++ b/sdk/go/openshell/v1/internal/converter/coverage_test.go @@ -78,20 +78,37 @@ func TestConverterCoversAllProtoFields_SandboxCondition(t *testing.T) { func TestConverterCoversAllProtoFields_SandboxPolicy(t *testing.T) { handled := fieldSet{ - "version": true, - "filesystem": true, - "network_policies": true, - "process": true, - "landlock": true, + "version": true, + "filesystem": true, + "network_policies": true, + "process": true, + "landlock": true, + "network_middlewares": true, } - skipped := fieldSet{ - // Middleware support is not yet exposed in the SDK domain model. - // Tracked in GitHub issue #36 for Drop D. - "network_middlewares": true, + assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_NetworkMiddlewareConfig(t *testing.T) { + handled := fieldSet{ + "name": true, + "middleware": true, + "config": true, + "on_error": true, + "endpoints": true, + "order": true, + } + + assertAllFieldsCovered(t, (&sandboxpb.NetworkMiddlewareConfig{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_MiddlewareEndpointSelector(t *testing.T) { + handled := fieldSet{ + "include": true, + "exclude": true, } - assertAllFieldsCovered(t, (&sandboxpb.SandboxPolicy{}).ProtoReflect().Descriptor(), handled, skipped) + assertAllFieldsCovered(t, (&sandboxpb.MiddlewareEndpointSelector{}).ProtoReflect().Descriptor(), handled, nil) } func TestConverterCoversAllProtoFields_NetworkEndpoint(t *testing.T) { @@ -179,6 +196,84 @@ func TestConverterCoversAllProtoFields_CredentialHandle(t *testing.T) { assertAllFieldsCovered(t, (&dm.CredentialHandle{}).ProtoReflect().Descriptor(), handled, nil) } +func TestConverterCoversAllProtoFields_SandboxPolicyRevision(t *testing.T) { + handled := fieldSet{ + "version": true, + "policy_hash": true, + "status": true, + "load_error": true, + "created_at_ms": true, + "loaded_at_ms": true, + "policy": true, + "provenance": true, + } + + assertAllFieldsCovered(t, (&pb.SandboxPolicyRevision{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_ProviderProfile(t *testing.T) { + handled := fieldSet{ + "id": true, + "display_name": true, + "description": true, + "category": true, + "credentials": true, + "endpoints": true, + "binaries": true, + "inference_capable": true, + "discovery": true, + "resource_version": true, + "annotations": true, + "source": true, + "scope": true, + } + + assertAllFieldsCovered(t, (&pb.ProviderProfile{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_ProviderProfileCredential(t *testing.T) { + handled := fieldSet{ + "name": true, + "description": true, + "env_vars": true, + "required": true, + "auth_style": true, + "header_name": true, + "query_param": true, + "refresh": true, + "path_template": true, + "token_grant": true, + } + + assertAllFieldsCovered(t, (&pb.ProviderProfileCredential{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_ProviderCredentialTokenGrant(t *testing.T) { + handled := fieldSet{ + "token_endpoint": true, + "audience": true, + "jwt_svid_audience": true, + "scopes": true, + "cache_ttl_seconds": true, + "audience_overrides": true, + "client_assertion_type": true, + } + + assertAllFieldsCovered(t, (&pb.ProviderCredentialTokenGrant{}).ProtoReflect().Descriptor(), handled, nil) +} + +func TestConverterCoversAllProtoFields_ProviderCredentialTokenGrantAudienceOverride(t *testing.T) { + handled := fieldSet{ + "host": true, + "port": true, + "path": true, + "audience": true, + "scopes": true, + } + + assertAllFieldsCovered(t, (&pb.ProviderCredentialTokenGrantAudienceOverride{}).ProtoReflect().Descriptor(), handled, nil) +} + func TestConverterCoversAllProtoFields_McpOptions(t *testing.T) { handled := fieldSet{ "strict_tool_names": true, diff --git a/sdk/go/openshell/v1/internal/converter/errors.go b/sdk/go/openshell/v1/internal/converter/errors.go index d589088e88..7bf16f1359 100644 --- a/sdk/go/openshell/v1/internal/converter/errors.go +++ b/sdk/go/openshell/v1/internal/converter/errors.go @@ -15,14 +15,16 @@ var grpcToSDK = map[codes.Code]types.ErrorCode{ codes.AlreadyExists: types.ErrorAlreadyExists, codes.Unavailable: types.ErrorUnavailable, codes.PermissionDenied: types.ErrorPermissionDenied, - codes.Unauthenticated: types.ErrorUnauthenticated, codes.InvalidArgument: types.ErrorInvalidArgument, codes.DeadlineExceeded: types.ErrorDeadlineExceeded, codes.Canceled: types.ErrorCancelled, codes.Internal: types.ErrorInternal, codes.Unimplemented: types.ErrorUnimplemented, codes.Aborted: types.ErrorConflict, + codes.Unauthenticated: types.ErrorUnauthenticated, codes.FailedPrecondition: types.ErrorConflict, + codes.ResourceExhausted: types.ErrorUnavailable, + codes.OutOfRange: types.ErrorInvalidArgument, } // FromGRPCError converts a gRPC error to a typed StatusError. diff --git a/sdk/go/openshell/v1/internal/converter/exec.go b/sdk/go/openshell/v1/internal/converter/exec.go new file mode 100644 index 0000000000..0ca7157265 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/exec.go @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "fmt" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// ExecChunkFromEvent converts a proto ExecSandboxEvent to an ExecChunk and/or exit code. +// For stdout/stderr events, returns the chunk with exitCode -1. +// For exit events, returns nil chunk with the exit code. +func ExecChunkFromEvent(event *pb.ExecSandboxEvent) (*types.ExecChunk, int, error) { + if event == nil { + return nil, -1, fmt.Errorf("nil exec event") + } + + switch p := event.Payload.(type) { + case *pb.ExecSandboxEvent_Stdout: + return &types.ExecChunk{ + Stream: types.StreamStdout, + Data: append([]byte(nil), p.Stdout.GetData()...), + }, -1, nil + case *pb.ExecSandboxEvent_Stderr: + return &types.ExecChunk{ + Stream: types.StreamStderr, + Data: append([]byte(nil), p.Stderr.GetData()...), + }, -1, nil + case *pb.ExecSandboxEvent_Exit: + return nil, int(p.Exit.GetExitCode()), nil + default: + return nil, -1, fmt.Errorf("unknown exec event payload type: %T", p) + } +} + +// ExecRequestToProto builds a proto ExecSandboxRequest for Run/Stream modes. +func ExecRequestToProto(sandboxID string, command []string, opts *types.ExecOptions) *pb.ExecSandboxRequest { + req := &pb.ExecSandboxRequest{ + SandboxId: sandboxID, + Command: CopyStringSlice(command), + } + if opts != nil { + req.Workdir = opts.WorkDir + req.Environment = CopyStringMap(opts.Env) + } + return req +} + +// ExecInteractiveRequestToProto builds a proto ExecSandboxRequest for Interactive mode. +func ExecInteractiveRequestToProto(sandboxID string, command []string, cols, rows uint32, opts *types.ExecOptions) *pb.ExecSandboxRequest { + req := ExecRequestToProto(sandboxID, command, opts) + req.Tty = true + req.Cols = cols + req.Rows = rows + return req +} + +// ExecResultFromEvents collects a sequence of ExecSandboxEvents into an ExecResult. +func ExecResultFromEvents(events []*pb.ExecSandboxEvent) (*types.ExecResult, error) { + if len(events) == 0 { + return nil, fmt.Errorf("no exec events received") + } + + var stdout, stderr []byte + exitCode := -1 + sawExit := false + + for _, event := range events { + chunk, code, err := ExecChunkFromEvent(event) + if err != nil { + return nil, err + } + if chunk != nil { + switch chunk.Stream { + case types.StreamStdout: + stdout = append(stdout, chunk.Data...) + case types.StreamStderr: + stderr = append(stderr, chunk.Data...) + } + } else { + exitCode = code + sawExit = true + } + } + + if !sawExit { + return nil, fmt.Errorf("no exit event received") + } + + return &types.ExecResult{ + ExitCode: exitCode, + Stdout: stdout, + Stderr: stderr, + }, nil +} diff --git a/sdk/go/openshell/v1/internal/converter/exec_test.go b/sdk/go/openshell/v1/internal/converter/exec_test.go new file mode 100644 index 0000000000..078a72d4d1 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/exec_test.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestExecChunkFromEvent_Stdout(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stdout{ + Stdout: &pb.ExecSandboxStdout{ + Data: []byte("hello world"), + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStdout, chunk.Stream) + assert.Equal(t, []byte("hello world"), chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecChunkFromEvent_Stderr(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stderr{ + Stderr: &pb.ExecSandboxStderr{ + Data: []byte("error output"), + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStderr, chunk.Stream) + assert.Equal(t, []byte("error output"), chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecChunkFromEvent_Exit(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Exit{ + Exit: &pb.ExecSandboxExit{ + ExitCode: 42, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + assert.Nil(t, chunk) + assert.Equal(t, 42, exitCode) +} + +func TestExecChunkFromEvent_ExitZero(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Exit{ + Exit: &pb.ExecSandboxExit{ + ExitCode: 0, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + assert.Nil(t, chunk) + assert.Equal(t, 0, exitCode) +} + +func TestExecChunkFromEvent_NilEvent(t *testing.T) { + _, _, err := ExecChunkFromEvent(nil) + assert.Error(t, err) +} + +func TestExecChunkFromEvent_NilPayload(t *testing.T) { + event := &pb.ExecSandboxEvent{} + + _, _, err := ExecChunkFromEvent(event) + assert.Error(t, err) +} + +func TestExecChunkFromEvent_EmptyStdout(t *testing.T) { + event := &pb.ExecSandboxEvent{ + Payload: &pb.ExecSandboxEvent_Stdout{ + Stdout: &pb.ExecSandboxStdout{ + Data: []byte{}, + }, + }, + } + + chunk, exitCode, err := ExecChunkFromEvent(event) + + require.NoError(t, err) + require.NotNil(t, chunk) + assert.Equal(t, v1.StreamStdout, chunk.Stream) + assert.Empty(t, chunk.Data) + assert.Equal(t, -1, exitCode) +} + +func TestExecRequestToProto(t *testing.T) { + req := ExecRequestToProto("sb-1", []string{"ls", "-la"}, &v1.ExecOptions{ + Env: map[string]string{"FOO": "bar"}, + WorkDir: "/home/user", + }) + + require.NotNil(t, req) + assert.Equal(t, "sb-1", req.SandboxId) + assert.Equal(t, []string{"ls", "-la"}, req.Command) + assert.Equal(t, "/home/user", req.Workdir) + assert.Equal(t, map[string]string{"FOO": "bar"}, req.Environment) + assert.False(t, req.Tty) +} + +func TestExecRequestToProto_NilOptions(t *testing.T) { + req := ExecRequestToProto("sb-2", []string{"echo", "hi"}, nil) + + require.NotNil(t, req) + assert.Equal(t, "sb-2", req.SandboxId) + assert.Equal(t, []string{"echo", "hi"}, req.Command) + assert.Empty(t, req.Workdir) + assert.Nil(t, req.Environment) +} + +func TestExecRequestToProto_Interactive(t *testing.T) { + req := ExecInteractiveRequestToProto("sb-3", []string{"/bin/bash"}, 80, 24, &v1.ExecOptions{ + Env: map[string]string{"TERM": "xterm"}, + WorkDir: "/root", + }) + + require.NotNil(t, req) + assert.Equal(t, "sb-3", req.SandboxId) + assert.Equal(t, []string{"/bin/bash"}, req.Command) + assert.Equal(t, "/root", req.Workdir) + assert.Equal(t, map[string]string{"TERM": "xterm"}, req.Environment) + assert.True(t, req.Tty) + assert.Equal(t, uint32(80), req.Cols) + assert.Equal(t, uint32(24), req.Rows) +} + +func TestExecResultFromEvents(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line1\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stderr{Stderr: &pb.ExecSandboxStderr{Data: []byte("warn\n")}}}, + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("line2\n")}}}, + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 0}}}, + } + + result, err := ExecResultFromEvents(events) + + require.NoError(t, err) + assert.Equal(t, 0, result.ExitCode) + assert.Equal(t, []byte("line1\nline2\n"), result.Stdout) + assert.Equal(t, []byte("warn\n"), result.Stderr) +} + +func TestExecResultFromEvents_NoExit(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Stdout{Stdout: &pb.ExecSandboxStdout{Data: []byte("data")}}}, + } + + _, err := ExecResultFromEvents(events) + assert.Error(t, err) +} + +func TestExecResultFromEvents_Empty(t *testing.T) { + _, err := ExecResultFromEvents(nil) + assert.Error(t, err) +} + +func TestExecResultFromEvents_OnlyExit(t *testing.T) { + events := []*pb.ExecSandboxEvent{ + {Payload: &pb.ExecSandboxEvent_Exit{Exit: &pb.ExecSandboxExit{ExitCode: 1}}}, + } + + result, err := ExecResultFromEvents(events) + + require.NoError(t, err) + assert.Equal(t, 1, result.ExitCode) + assert.Empty(t, result.Stdout) + assert.Empty(t, result.Stderr) +} diff --git a/sdk/go/openshell/v1/internal/converter/health.go b/sdk/go/openshell/v1/internal/converter/health.go new file mode 100644 index 0000000000..63ab8c3298 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/health.go @@ -0,0 +1,68 @@ +// 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" +) + +// GatewayInfoFromProto converts a proto GetGatewayInfoResponse to an SDK GatewayInfo. +func GatewayInfoFromProto(resp *pb.GetGatewayInfoResponse) *types.GatewayInfo { + if resp == nil { + return nil + } + + drivers := make([]types.ComputeDriverInfo, 0, len(resp.GetComputeDrivers())) + for _, d := range resp.GetComputeDrivers() { + drivers = append(drivers, ComputeDriverInfoFromProto(d)) + } + + return &types.GatewayInfo{ + Status: ServiceStatusFromProto(resp.GetStatus()), + Version: resp.GetGatewayVersion(), + ComputeDrivers: drivers, + } +} + +// ServiceStatusFromProto converts a proto ServiceStatus to an SDK ServiceStatus. +func ServiceStatusFromProto(status pb.ServiceStatus) types.ServiceStatus { + switch status { + case pb.ServiceStatus_SERVICE_STATUS_HEALTHY: + return types.ServiceStatusHealthy + case pb.ServiceStatus_SERVICE_STATUS_DEGRADED: + return types.ServiceStatusDegraded + case pb.ServiceStatus_SERVICE_STATUS_UNHEALTHY: + return types.ServiceStatusUnhealthy + default: + return types.ServiceStatusUnknown + } +} + +// ComputeDriverInfoFromProto converts a proto ComputeDriverInfo to an SDK ComputeDriverInfo. +func ComputeDriverInfoFromProto(d *pb.ComputeDriverInfo) types.ComputeDriverInfo { + result := types.ComputeDriverInfo{ + Name: d.GetName(), + } + if caps := d.GetCapabilities(); caps != nil { + result.DriverName = caps.GetDriverName() + result.DriverVersion = caps.GetDriverVersion() + } + return result +} + +// CurrentUserFromProto converts a proto GetCurrentUserResponse to an SDK CurrentUser. +func CurrentUserFromProto(resp *pb.GetCurrentUserResponse) *types.CurrentUser { + if resp == nil { + return nil + } + + return &types.CurrentUser{ + Subject: resp.GetSubject(), + DisplayName: resp.GetDisplayName(), + Roles: CopyStringSlice(resp.GetRoles()), + Scopes: CopyStringSlice(resp.GetScopes()), + IdentityProvider: resp.GetIdentityProvider(), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/health_test.go b/sdk/go/openshell/v1/internal/converter/health_test.go new file mode 100644 index 0000000000..d0360a9b66 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/health_test.go @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGatewayInfoFromProto(t *testing.T) { + proto := &pb.GetGatewayInfoResponse{ + Status: pb.ServiceStatus_SERVICE_STATUS_HEALTHY, + GatewayVersion: "1.5.0", + ComputeDrivers: []*pb.ComputeDriverInfo{ + { + Name: "k8s", + Capabilities: &pb.ComputeDriverCapabilities{ + DriverName: "kubernetes", + DriverVersion: "2.1.0", + }, + }, + { + Name: "docker", + Capabilities: &pb.ComputeDriverCapabilities{ + DriverName: "docker-engine", + DriverVersion: "24.0.0", + }, + }, + }, + } + + info := GatewayInfoFromProto(proto) + + require.NotNil(t, info) + assert.Equal(t, v1.ServiceStatusHealthy, info.Status) + assert.Equal(t, "1.5.0", info.Version) + require.Len(t, info.ComputeDrivers, 2) + assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) + assert.Equal(t, "kubernetes", info.ComputeDrivers[0].DriverName) + assert.Equal(t, "2.1.0", info.ComputeDrivers[0].DriverVersion) + assert.Equal(t, "docker", info.ComputeDrivers[1].Name) + assert.Equal(t, "docker-engine", info.ComputeDrivers[1].DriverName) +} + +func TestGatewayInfoFromProto_NoDrivers(t *testing.T) { + proto := &pb.GetGatewayInfoResponse{ + Status: pb.ServiceStatus_SERVICE_STATUS_DEGRADED, + GatewayVersion: "1.0.0", + } + + info := GatewayInfoFromProto(proto) + + require.NotNil(t, info) + assert.Equal(t, v1.ServiceStatusDegraded, info.Status) + assert.Empty(t, info.ComputeDrivers) +} + +func TestGatewayInfoFromProto_Nil(t *testing.T) { + info := GatewayInfoFromProto(nil) + assert.Nil(t, info) +} + +func TestGatewayInfoFromProto_DeepCopy(t *testing.T) { + proto := &pb.GetGatewayInfoResponse{ + Status: pb.ServiceStatus_SERVICE_STATUS_HEALTHY, + GatewayVersion: "1.0.0", + ComputeDrivers: []*pb.ComputeDriverInfo{ + {Name: "k8s", Capabilities: &pb.ComputeDriverCapabilities{DriverName: "kubernetes"}}, + }, + } + + info := GatewayInfoFromProto(proto) + proto.ComputeDrivers[0].Name = "mutated" + + assert.Equal(t, "k8s", info.ComputeDrivers[0].Name) +} + +func TestServiceStatusFromProto(t *testing.T) { + tests := []struct { + proto pb.ServiceStatus + expected v1.ServiceStatus + }{ + {pb.ServiceStatus_SERVICE_STATUS_HEALTHY, v1.ServiceStatusHealthy}, + {pb.ServiceStatus_SERVICE_STATUS_DEGRADED, v1.ServiceStatusDegraded}, + {pb.ServiceStatus_SERVICE_STATUS_UNHEALTHY, v1.ServiceStatusUnhealthy}, + {pb.ServiceStatus_SERVICE_STATUS_UNSPECIFIED, v1.ServiceStatusUnknown}, + {pb.ServiceStatus(99), v1.ServiceStatusUnknown}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, ServiceStatusFromProto(tt.proto)) + } +} + +func TestComputeDriverInfoFromProto_NilCapabilities(t *testing.T) { + proto := &pb.ComputeDriverInfo{ + Name: "bare-metal", + } + + info := ComputeDriverInfoFromProto(proto) + + assert.Equal(t, "bare-metal", info.Name) + assert.Empty(t, info.DriverName) + assert.Empty(t, info.DriverVersion) +} + +func TestCurrentUserFromProto(t *testing.T) { + proto := &pb.GetCurrentUserResponse{ + Subject: "user-123", + DisplayName: "Test User", + Roles: []string{"admin", "viewer"}, + Scopes: []string{"read", "write"}, + IdentityProvider: "oidc-provider", + } + + user := CurrentUserFromProto(proto) + + require.NotNil(t, user) + assert.Equal(t, "user-123", user.Subject) + assert.Equal(t, "Test User", user.DisplayName) + assert.Equal(t, []string{"admin", "viewer"}, user.Roles) + assert.Equal(t, []string{"read", "write"}, user.Scopes) + assert.Equal(t, "oidc-provider", user.IdentityProvider) +} + +func TestCurrentUserFromProto_DeepCopy(t *testing.T) { + roles := []string{"admin"} + proto := &pb.GetCurrentUserResponse{ + Subject: "user-1", + Roles: roles, + } + + user := CurrentUserFromProto(proto) + roles[0] = "mutated" + + assert.Equal(t, "admin", user.Roles[0]) +} + +func TestCurrentUserFromProto_Nil(t *testing.T) { + user := CurrentUserFromProto(nil) + assert.Nil(t, user) +} + +func TestCurrentUserFromProto_EmptyFields(t *testing.T) { + proto := &pb.GetCurrentUserResponse{ + Subject: "minimal-user", + } + + user := CurrentUserFromProto(proto) + + require.NotNil(t, user) + assert.Equal(t, "minimal-user", user.Subject) + assert.Empty(t, user.DisplayName) + assert.Nil(t, user.Roles) + assert.Nil(t, user.Scopes) + assert.Empty(t, user.IdentityProvider) +} diff --git a/sdk/go/openshell/v1/internal/converter/inference.go b/sdk/go/openshell/v1/internal/converter/inference.go new file mode 100644 index 0000000000..8d5f2aebd3 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/inference.go @@ -0,0 +1,75 @@ +// 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/inferencev1" +) + +// InferenceRouteConfigToProto converts an SDK InferenceRouteConfig plus +// workspace into a proto SetInferenceRouteRequest. +func InferenceRouteConfigToProto(workspace string, cfg *types.InferenceRouteConfig) *pb.SetInferenceRouteRequest { + if cfg == nil { + return &pb.SetInferenceRouteRequest{Workspace: workspace} + } + return &pb.SetInferenceRouteRequest{ + ProviderName: cfg.ProviderName, + ModelId: cfg.ModelID, + RouteName: cfg.RouteName, + NoVerify: cfg.NoVerify, + TimeoutSecs: cfg.TimeoutSecs, + Workspace: workspace, + } +} + +// InferenceRouteFromSetResponse converts a proto SetInferenceRouteResponse +// to an SDK InferenceRoute. +func InferenceRouteFromSetResponse(resp *pb.SetInferenceRouteResponse) *types.InferenceRoute { + if resp == nil { + return nil + } + return &types.InferenceRoute{ + ProviderName: resp.GetProviderName(), + ModelID: resp.GetModelId(), + Version: resp.GetVersion(), + RouteName: resp.GetRouteName(), + TimeoutSecs: resp.GetTimeoutSecs(), + Workspace: resp.GetWorkspace(), + ValidationPerformed: resp.GetValidationPerformed(), + ValidatedEndpoints: validatedEndpointsFromProto(resp.GetValidatedEndpoints()), + } +} + +// InferenceRouteFromGetResponse converts a proto GetInferenceRouteResponse +// to an SDK InferenceRoute. +func InferenceRouteFromGetResponse(resp *pb.GetInferenceRouteResponse) *types.InferenceRoute { + if resp == nil { + return nil + } + return &types.InferenceRoute{ + ProviderName: resp.GetProviderName(), + ModelID: resp.GetModelId(), + Version: resp.GetVersion(), + RouteName: resp.GetRouteName(), + TimeoutSecs: resp.GetTimeoutSecs(), + Workspace: resp.GetWorkspace(), + } +} + +// validatedEndpointsFromProto converts a slice of proto ValidatedEndpoint +// to SDK ValidatedEndpoint values. Returns nil for nil or empty input. +func validatedEndpointsFromProto(eps []*pb.ValidatedEndpoint) []types.ValidatedEndpoint { + if len(eps) == 0 { + return nil + } + result := make([]types.ValidatedEndpoint, len(eps)) + for i, ep := range eps { + result[i] = types.ValidatedEndpoint{ + URL: ep.GetUrl(), + Protocol: ep.GetProtocol(), + } + } + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/inference_test.go b/sdk/go/openshell/v1/internal/converter/inference_test.go new file mode 100644 index 0000000000..c5b9befb62 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/inference_test.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/inferencev1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestInferenceRouteConfigToProto(t *testing.T) { + cfg := &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "my-route", + NoVerify: true, + TimeoutSecs: 120, + } + + req := InferenceRouteConfigToProto("team-alpha", cfg) + + assert.Equal(t, "openai", req.GetProviderName()) + assert.Equal(t, "gpt-4", req.GetModelId()) + assert.Equal(t, "my-route", req.GetRouteName()) + assert.True(t, req.GetNoVerify()) + assert.False(t, req.GetVerify()) + assert.Equal(t, uint64(120), req.GetTimeoutSecs()) + assert.Equal(t, "team-alpha", req.GetWorkspace()) +} + +func TestInferenceRouteConfigToProto_NilConfig(t *testing.T) { + req := InferenceRouteConfigToProto("ws", nil) + + assert.Equal(t, "ws", req.GetWorkspace()) + assert.Empty(t, req.GetProviderName()) +} + +func TestInferenceRouteConfigToProto_EmptyRouteName(t *testing.T) { + cfg := &types.InferenceRouteConfig{ + ProviderName: "openai", + ModelID: "gpt-4", + RouteName: "", + } + + req := InferenceRouteConfigToProto("ws", cfg) + + assert.Empty(t, req.GetRouteName()) +} + +func TestInferenceRouteFromSetResponse(t *testing.T) { + resp := &pb.SetInferenceRouteResponse{ + ProviderName: "openai", + ModelId: "gpt-4", + Version: 5, + RouteName: "my-route", + ValidationPerformed: true, + ValidatedEndpoints: []*pb.ValidatedEndpoint{ + {Url: "https://api.openai.com/v1", Protocol: "openai"}, + {Url: "https://backup.openai.com/v1", Protocol: "openai"}, + }, + TimeoutSecs: 120, + Workspace: "team-alpha", + } + + route := InferenceRouteFromSetResponse(resp) + + require.NotNil(t, route) + assert.Equal(t, "openai", route.ProviderName) + assert.Equal(t, "gpt-4", route.ModelID) + assert.Equal(t, uint64(5), route.Version) + assert.Equal(t, "my-route", route.RouteName) + assert.True(t, route.ValidationPerformed) + require.Len(t, route.ValidatedEndpoints, 2) + assert.Equal(t, "https://api.openai.com/v1", route.ValidatedEndpoints[0].URL) + assert.Equal(t, "openai", route.ValidatedEndpoints[0].Protocol) + assert.Equal(t, "https://backup.openai.com/v1", route.ValidatedEndpoints[1].URL) + assert.Equal(t, uint64(120), route.TimeoutSecs) + assert.Equal(t, "team-alpha", route.Workspace) +} + +func TestInferenceRouteFromSetResponse_Nil(t *testing.T) { + route := InferenceRouteFromSetResponse(nil) + assert.Nil(t, route) +} + +func TestInferenceRouteFromSetResponse_NoEndpoints(t *testing.T) { + resp := &pb.SetInferenceRouteResponse{ + ProviderName: "openai", + ModelId: "gpt-4", + Version: 1, + ValidationPerformed: false, + } + + route := InferenceRouteFromSetResponse(resp) + + require.NotNil(t, route) + assert.Nil(t, route.ValidatedEndpoints) + assert.False(t, route.ValidationPerformed) +} + +func TestInferenceRouteFromGetResponse(t *testing.T) { + resp := &pb.GetInferenceRouteResponse{ + ProviderName: "vertex", + ModelId: "gemini-pro", + Version: 3, + RouteName: "default", + TimeoutSecs: 60, + Workspace: "prod", + } + + route := InferenceRouteFromGetResponse(resp) + + require.NotNil(t, route) + assert.Equal(t, "vertex", route.ProviderName) + assert.Equal(t, "gemini-pro", route.ModelID) + assert.Equal(t, uint64(3), route.Version) + assert.Equal(t, "default", route.RouteName) + assert.Equal(t, uint64(60), route.TimeoutSecs) + assert.Equal(t, "prod", route.Workspace) + assert.False(t, route.ValidationPerformed) + assert.Nil(t, route.ValidatedEndpoints) +} + +func TestInferenceRouteFromGetResponse_Nil(t *testing.T) { + route := InferenceRouteFromGetResponse(nil) + assert.Nil(t, route) +} + +func TestInferenceRouteFromSetResponse_DeepCopy(t *testing.T) { + protoEndpoints := []*pb.ValidatedEndpoint{ + {Url: "https://original.com", Protocol: "openai"}, + } + resp := &pb.SetInferenceRouteResponse{ + ProviderName: "openai", + ModelId: "gpt-4", + Version: 1, + ValidatedEndpoints: protoEndpoints, + } + + route := InferenceRouteFromSetResponse(resp) + + // Mutate the proto source; SDK value should be unaffected. + protoEndpoints[0].Url = "https://mutated.com" + assert.Equal(t, "https://original.com", route.ValidatedEndpoints[0].URL) +} + +func TestInferenceRoundTrip(t *testing.T) { + cfg := &types.InferenceRouteConfig{ + ProviderName: "anthropic", + ModelID: "claude-4", + RouteName: "inference-route", + NoVerify: false, + TimeoutSecs: 90, + } + + req := InferenceRouteConfigToProto("my-ws", cfg) + + assert.Equal(t, cfg.ProviderName, req.GetProviderName()) + assert.Equal(t, cfg.ModelID, req.GetModelId()) + assert.Equal(t, cfg.RouteName, req.GetRouteName()) + assert.Equal(t, cfg.NoVerify, req.GetNoVerify()) + assert.Equal(t, cfg.TimeoutSecs, req.GetTimeoutSecs()) + assert.Equal(t, "my-ws", req.GetWorkspace()) +} diff --git a/sdk/go/openshell/v1/internal/converter/network_policy.go b/sdk/go/openshell/v1/internal/converter/network_policy.go index d5c8c2872b..695a80fefd 100644 --- a/sdk/go/openshell/v1/internal/converter/network_policy.go +++ b/sdk/go/openshell/v1/internal/converter/network_policy.go @@ -80,10 +80,7 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor CredentialSigning: ep.GetCredentialSigning(), SigningService: ep.GetSigningService(), SigningRegion: ep.GetSigningRegion(), - JsonRpcMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(), - } - if mcp := ep.GetMcp(); mcp != nil { - result.Mcp = mcpOptionsFromProto(mcp) + JSONRPCMaxBodyBytes: ep.GetJsonRpcMaxBodyBytes(), } if binding := ep.GetCredentialBinding(); binding != nil { result.CredentialBinding = &types.NetworkCredentialBinding{ @@ -121,6 +118,7 @@ func policyNetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) types.PolicyNetwor } } } + result.Mcp = mcpOptionsFromProto(ep.GetMcp()) return result } @@ -142,10 +140,7 @@ func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.Network CredentialSigning: ep.CredentialSigning, SigningService: ep.SigningService, SigningRegion: ep.SigningRegion, - JsonRpcMaxBodyBytes: ep.JsonRpcMaxBodyBytes, - } - if ep.Mcp != nil { - result.Mcp = mcpOptionsToProto(ep.Mcp) + JsonRpcMaxBodyBytes: ep.JSONRPCMaxBodyBytes, } if ep.CredentialBinding != nil { result.CredentialBinding = &sbv1.NetworkCredentialBinding{ @@ -177,9 +172,32 @@ func policyNetworkEndpointToProto(ep *types.PolicyNetworkEndpoint) *sbv1.Network result.GraphqlPersistedQueries[k] = graphqlOperationToProto(&v) } } + result.Mcp = mcpOptionsToProto(ep.Mcp) return result } +// --- McpOptions --- + +func mcpOptionsFromProto(m *sbv1.McpOptions) *types.McpOptions { + if m == nil { + return nil + } + return &types.McpOptions{ + StrictToolNames: CopyBoolPtr(m.StrictToolNames), + AllowAllKnownMcpMethods: CopyBoolPtr(m.AllowAllKnownMcpMethods), + } +} + +func mcpOptionsToProto(m *types.McpOptions) *sbv1.McpOptions { + if m == nil { + return nil + } + return &sbv1.McpOptions{ + StrictToolNames: CopyBoolPtr(m.StrictToolNames), + AllowAllKnownMcpMethods: CopyBoolPtr(m.AllowAllKnownMcpMethods), + } +} + // --- L7Rule --- func l7RuleFromProto(r *sbv1.L7Rule) types.L7Rule { @@ -227,19 +245,16 @@ func l7RuleToProto(r *types.L7Rule) *sbv1.L7Rule { // --- L7DenyRule --- func l7DenyRuleFromProto(r *sbv1.L7DenyRule) types.L7DenyRule { - result := types.L7DenyRule{ + return types.L7DenyRule{ Method: r.GetMethod(), Path: r.GetPath(), Command: r.GetCommand(), OperationType: r.GetOperationType(), OperationName: r.GetOperationName(), Fields: CopyStringSlice(r.GetFields()), - Query: l7QueryMapFromProtoDeny(r.GetQuery()), + Query: l7QueryMapFromProto(r.GetQuery()), + Params: l7QueryMapFromProto(r.GetParams()), } - if p := r.GetParams(); len(p) > 0 { - result.Params = l7QueryMapFromProto(p) - } - return result } func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule { @@ -252,7 +267,7 @@ func l7DenyRuleToProto(r *types.L7DenyRule) *sbv1.L7DenyRule { Fields: CopyStringSlice(r.Fields), } if len(r.Query) > 0 { - result.Query = l7QueryMapToProtoDeny(r.Query) + result.Query = l7QueryMapToProto(r.Query) } if len(r.Params) > 0 { result.Params = l7QueryMapToProto(r.Params) @@ -292,14 +307,6 @@ func l7QueryMapToProto(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7Que return result } -// L7DenyRule uses the same L7QueryMatcher proto type but on a different message. -func l7QueryMapFromProtoDeny(m map[string]*sbv1.L7QueryMatcher) map[string]types.L7QueryMatcher { - return l7QueryMapFromProto(m) -} - -func l7QueryMapToProtoDeny(m map[string]types.L7QueryMatcher) map[string]*sbv1.L7QueryMatcher { - return l7QueryMapToProto(m) -} // --- GraphqlOperation --- @@ -318,25 +325,3 @@ func graphqlOperationToProto(op *types.GraphqlOperation) *sbv1.GraphqlOperation Fields: CopyStringSlice(op.Fields), } } - -// --- McpOptions --- - -func mcpOptionsFromProto(m *sbv1.McpOptions) *types.McpOptions { - if m == nil { - return nil - } - return &types.McpOptions{ - StrictToolNames: m.StrictToolNames, - AllowAllKnownMcpMethods: m.AllowAllKnownMcpMethods, - } -} - -func mcpOptionsToProto(m *types.McpOptions) *sbv1.McpOptions { - if m == nil { - return nil - } - return &sbv1.McpOptions{ - StrictToolNames: m.StrictToolNames, - AllowAllKnownMcpMethods: m.AllowAllKnownMcpMethods, - } -} diff --git a/sdk/go/openshell/v1/internal/converter/network_policy_test.go b/sdk/go/openshell/v1/internal/converter/network_policy_test.go new file mode 100644 index 0000000000..d623cbc903 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/network_policy_test.go @@ -0,0 +1,344 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- NetworkPolicyRule round-trip --- + +func TestNetworkPolicyRuleFromProto(t *testing.T) { + proto := &sbv1.NetworkPolicyRule{ + Name: "web-api", + Endpoints: []*sbv1.NetworkEndpoint{ + { + Host: "api.example.com", + Port: 443, + Protocol: "rest", + Tls: "strict", + Enforcement: "enforce", + Access: "allow", + Ports: []uint32{80, 443}, + AllowedIps: []string{"10.0.0.1", "10.0.0.2"}, + AllowEncodedSlash: true, + PersistedQueries: "allow", + GraphqlMaxBodyBytes: 1024, + Path: "/api/v1", + WebsocketCredentialRewrite: true, + RequestBodyCredentialRewrite: false, + AdvisorProposed: true, + CredentialSigning: "sigv4", + SigningService: "bedrock", + SigningRegion: "us-west-2", + JsonRpcMaxBodyBytes: 65536, + Mcp: &sbv1.McpOptions{ + StrictToolNames: boolPtr(true), + AllowAllKnownMcpMethods: boolPtr(false), + }, + Rules: []*sbv1.L7Rule{ + { + Allow: &sbv1.L7Allow{ + Method: "GET", + Path: "/users", + Command: "list", + Query: map[string]*sbv1.L7QueryMatcher{ + "page": {Glob: "[0-9]*", Any: []string{"1", "2"}}, + }, + OperationType: "query", + OperationName: "GetUsers", + Fields: []string{"id", "name"}, + Params: map[string]*sbv1.L7QueryMatcher{ + "name": {Glob: "my-tool-*"}, + }, + }, + }, + }, + DenyRules: []*sbv1.L7DenyRule{ + { + Method: "DELETE", + Path: "/admin", + Command: "rm", + OperationType: "mutation", + OperationName: "DeleteAll", + Fields: []string{"*"}, + Query: map[string]*sbv1.L7QueryMatcher{ + "force": {Glob: "true"}, + }, + Params: map[string]*sbv1.L7QueryMatcher{ + "tool": {Glob: "deny-*"}, + }, + }, + }, + GraphqlPersistedQueries: map[string]*sbv1.GraphqlOperation{ + "abc123": { + OperationType: "query", + OperationName: "GetUser", + Fields: []string{"id", "email"}, + }, + }, + }, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/curl"}, + }, + } + + rule := NetworkPolicyRuleFromProto(proto) + + require.NotNil(t, rule) + assert.Equal(t, "web-api", rule.Name) + require.Len(t, rule.Endpoints, 1) + ep := rule.Endpoints[0] + assert.Equal(t, "api.example.com", ep.Host) + assert.Equal(t, uint32(443), ep.Port) + assert.Equal(t, "rest", ep.Protocol) + assert.Equal(t, "strict", ep.TLS) + assert.Equal(t, "enforce", ep.Enforcement) + assert.Equal(t, "allow", ep.Access) + assert.Equal(t, []uint32{80, 443}, ep.Ports) + assert.Equal(t, []string{"10.0.0.1", "10.0.0.2"}, ep.AllowedIPs) + assert.True(t, ep.AllowEncodedSlash) + assert.Equal(t, "allow", ep.PersistedQueries) + assert.Equal(t, uint32(1024), ep.GraphqlMaxBodyBytes) + assert.Equal(t, "/api/v1", ep.Path) + assert.True(t, ep.WebsocketCredentialRewrite) + assert.False(t, ep.RequestBodyCredentialRewrite) + assert.True(t, ep.AdvisorProposed) + assert.Equal(t, "sigv4", ep.CredentialSigning) + assert.Equal(t, "bedrock", ep.SigningService) + assert.Equal(t, "us-west-2", ep.SigningRegion) + assert.Equal(t, uint32(65536), ep.JSONRPCMaxBodyBytes) + + // MCP options + require.NotNil(t, ep.Mcp) + require.NotNil(t, ep.Mcp.StrictToolNames) + assert.True(t, *ep.Mcp.StrictToolNames) + require.NotNil(t, ep.Mcp.AllowAllKnownMcpMethods) + assert.False(t, *ep.Mcp.AllowAllKnownMcpMethods) + + // L7 rules + require.Len(t, ep.Rules, 1) + allow := ep.Rules[0].Allow + require.NotNil(t, allow) + assert.Equal(t, "GET", allow.Method) + assert.Equal(t, "/users", allow.Path) + assert.Equal(t, "list", allow.Command) + assert.Equal(t, "query", allow.OperationType) + assert.Equal(t, "GetUsers", allow.OperationName) + assert.Equal(t, []string{"id", "name"}, allow.Fields) + require.Contains(t, allow.Query, "page") + assert.Equal(t, "[0-9]*", allow.Query["page"].Glob) + assert.Equal(t, []string{"1", "2"}, allow.Query["page"].Any) + require.Contains(t, allow.Params, "name") + assert.Equal(t, "my-tool-*", allow.Params["name"].Glob) + + // Deny rules + require.Len(t, ep.DenyRules, 1) + deny := ep.DenyRules[0] + assert.Equal(t, "DELETE", deny.Method) + assert.Equal(t, "/admin", deny.Path) + assert.Equal(t, "rm", deny.Command) + assert.Equal(t, "mutation", deny.OperationType) + assert.Equal(t, "DeleteAll", deny.OperationName) + assert.Equal(t, []string{"*"}, deny.Fields) + require.Contains(t, deny.Query, "force") + assert.Equal(t, "true", deny.Query["force"].Glob) + require.Contains(t, deny.Params, "tool") + assert.Equal(t, "deny-*", deny.Params["tool"].Glob) + + // GraphQL persisted queries + require.Contains(t, ep.GraphqlPersistedQueries, "abc123") + gql := ep.GraphqlPersistedQueries["abc123"] + assert.Equal(t, "query", gql.OperationType) + assert.Equal(t, "GetUser", gql.OperationName) + assert.Equal(t, []string{"id", "email"}, gql.Fields) + + // Binaries + require.Len(t, rule.Binaries, 1) + assert.Equal(t, "/usr/bin/curl", rule.Binaries[0].Path) +} + +func TestNetworkPolicyRuleFromProto_Nil(t *testing.T) { + assert.Nil(t, NetworkPolicyRuleFromProto(nil)) +} + +func TestNetworkPolicyRuleRoundTrip(t *testing.T) { + original := &v1.NetworkPolicyRule{ + Name: "graphql-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + { + Host: "gql.example.com", + Port: 8080, + Protocol: "graphql", + TLS: "permissive", + Enforcement: "audit", + Access: "allow", + Ports: []uint32{8080, 8443}, + AllowedIPs: []string{"192.168.1.0/24"}, + AllowEncodedSlash: false, + PersistedQueries: "enforce", + GraphqlMaxBodyBytes: 2048, + Path: "/graphql", + WebsocketCredentialRewrite: false, + RequestBodyCredentialRewrite: true, + AdvisorProposed: false, + CredentialSigning: "sigv4", + SigningService: "bedrock", + SigningRegion: "us-east-1", + JSONRPCMaxBodyBytes: 32768, + Mcp: &v1.McpOptions{ + StrictToolNames: boolPtr(true), + AllowAllKnownMcpMethods: boolPtr(false), + }, + Rules: []v1.L7Rule{ + { + Allow: &v1.L7Allow{ + Method: "POST", + Path: "/graphql", + OperationType: "query", + OperationName: "ListItems", + Fields: []string{"id"}, + Query: map[string]v1.L7QueryMatcher{ + "limit": {Glob: "[0-9]+"}, + }, + Params: map[string]v1.L7QueryMatcher{ + "tool": {Glob: "allowed-*"}, + }, + }, + }, + }, + DenyRules: []v1.L7DenyRule{ + { + Method: "POST", + Path: "/graphql", + OperationType: "mutation", + OperationName: "DropDB", + Params: map[string]v1.L7QueryMatcher{ + "tool": {Glob: "denied-*"}, + }, + }, + }, + GraphqlPersistedQueries: map[string]v1.GraphqlOperation{ + "hash1": { + OperationType: "query", + OperationName: "Safe", + Fields: []string{"f1"}, + }, + }, + }, + }, + Binaries: []v1.PolicyNetworkBinary{ + {Path: "/usr/bin/wget"}, + }, + } + + proto := NetworkPolicyRuleToProto(original) + require.NotNil(t, proto) + + roundTrip := NetworkPolicyRuleFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Name, roundTrip.Name) + require.Len(t, roundTrip.Endpoints, 1) + assert.Equal(t, original.Endpoints[0].Host, roundTrip.Endpoints[0].Host) + assert.Equal(t, original.Endpoints[0].Port, roundTrip.Endpoints[0].Port) + assert.Equal(t, original.Endpoints[0].Protocol, roundTrip.Endpoints[0].Protocol) + assert.Equal(t, original.Endpoints[0].TLS, roundTrip.Endpoints[0].TLS) + assert.Equal(t, original.Endpoints[0].Enforcement, roundTrip.Endpoints[0].Enforcement) + assert.Equal(t, original.Endpoints[0].Access, roundTrip.Endpoints[0].Access) + assert.Equal(t, original.Endpoints[0].Ports, roundTrip.Endpoints[0].Ports) + assert.Equal(t, original.Endpoints[0].AllowedIPs, roundTrip.Endpoints[0].AllowedIPs) + assert.Equal(t, original.Endpoints[0].AllowEncodedSlash, roundTrip.Endpoints[0].AllowEncodedSlash) + assert.Equal(t, original.Endpoints[0].GraphqlMaxBodyBytes, roundTrip.Endpoints[0].GraphqlMaxBodyBytes) + assert.Equal(t, original.Endpoints[0].AdvisorProposed, roundTrip.Endpoints[0].AdvisorProposed) + assert.Equal(t, original.Endpoints[0].CredentialSigning, roundTrip.Endpoints[0].CredentialSigning) + assert.Equal(t, original.Endpoints[0].SigningService, roundTrip.Endpoints[0].SigningService) + assert.Equal(t, original.Endpoints[0].SigningRegion, roundTrip.Endpoints[0].SigningRegion) + assert.Equal(t, original.Endpoints[0].JSONRPCMaxBodyBytes, roundTrip.Endpoints[0].JSONRPCMaxBodyBytes) + + // MCP round-trip + require.NotNil(t, roundTrip.Endpoints[0].Mcp) + assert.Equal(t, original.Endpoints[0].Mcp.StrictToolNames, roundTrip.Endpoints[0].Mcp.StrictToolNames) + assert.Equal(t, original.Endpoints[0].Mcp.AllowAllKnownMcpMethods, roundTrip.Endpoints[0].Mcp.AllowAllKnownMcpMethods) + + // L7 rules round-trip + require.Len(t, roundTrip.Endpoints[0].Rules, 1) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Method, roundTrip.Endpoints[0].Rules[0].Allow.Method) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.OperationName, roundTrip.Endpoints[0].Rules[0].Allow.OperationName) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Query["limit"].Glob, roundTrip.Endpoints[0].Rules[0].Allow.Query["limit"].Glob) + assert.Equal(t, original.Endpoints[0].Rules[0].Allow.Params["tool"].Glob, roundTrip.Endpoints[0].Rules[0].Allow.Params["tool"].Glob) + + // Deny rules round-trip + require.Len(t, roundTrip.Endpoints[0].DenyRules, 1) + assert.Equal(t, original.Endpoints[0].DenyRules[0].OperationName, roundTrip.Endpoints[0].DenyRules[0].OperationName) + assert.Equal(t, original.Endpoints[0].DenyRules[0].Params["tool"].Glob, roundTrip.Endpoints[0].DenyRules[0].Params["tool"].Glob) + + // GraphQL persisted queries round-trip + require.Contains(t, roundTrip.Endpoints[0].GraphqlPersistedQueries, "hash1") + + // Binaries round-trip + require.Len(t, roundTrip.Binaries, 1) + assert.Equal(t, original.Binaries[0].Path, roundTrip.Binaries[0].Path) +} + +func TestNetworkPolicyRuleToProto_Nil(t *testing.T) { + assert.Nil(t, NetworkPolicyRuleToProto(nil)) +} + +func TestNetworkPolicyRuleDeepCopy(t *testing.T) { + proto := &sbv1.NetworkPolicyRule{ + Name: "test", + Endpoints: []*sbv1.NetworkEndpoint{ + { + AllowedIps: []string{"1.2.3.4"}, + Ports: []uint32{80}, + Rules: []*sbv1.L7Rule{ + {Allow: &sbv1.L7Allow{Fields: []string{"f1"}}}, + }, + }, + }, + } + + rule := NetworkPolicyRuleFromProto(proto) + + // Mutate proto source + proto.Endpoints[0].AllowedIps[0] = "changed" + proto.Endpoints[0].Ports[0] = 9999 + proto.Endpoints[0].Rules[0].Allow.Fields[0] = "changed" + + // SDK type should be unaffected + assert.Equal(t, "1.2.3.4", rule.Endpoints[0].AllowedIPs[0]) + assert.Equal(t, uint32(80), rule.Endpoints[0].Ports[0]) + assert.Equal(t, "f1", rule.Endpoints[0].Rules[0].Allow.Fields[0]) + + // MCP deep copy + mcpProto := &sbv1.NetworkPolicyRule{ + Name: "mcp-test", + Endpoints: []*sbv1.NetworkEndpoint{ + { + Mcp: &sbv1.McpOptions{ + StrictToolNames: boolPtr(true), + }, + }, + }, + } + mcpRule := NetworkPolicyRuleFromProto(mcpProto) + *mcpProto.Endpoints[0].Mcp.StrictToolNames = false + require.NotNil(t, mcpRule.Endpoints[0].Mcp.StrictToolNames) + assert.True(t, *mcpRule.Endpoints[0].Mcp.StrictToolNames) +} + +func TestL7RuleFromProto_NilAllow(t *testing.T) { + proto := &sbv1.L7Rule{Allow: nil} + result := l7RuleFromProto(proto) + assert.Nil(t, result.Allow) +} + +func boolPtr(v bool) *bool { return &v } diff --git a/sdk/go/openshell/v1/internal/converter/policy.go b/sdk/go/openshell/v1/internal/converter/policy.go index 780fadb56c..0b20a7ddbe 100644 --- a/sdk/go/openshell/v1/internal/converter/policy.go +++ b/sdk/go/openshell/v1/internal/converter/policy.go @@ -4,9 +4,12 @@ package converter import ( + "fmt" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "google.golang.org/protobuf/types/known/structpb" ) // --- PolicyLoadStatus enum mapping --- @@ -117,6 +120,14 @@ func SandboxPolicyFromProto(p *sbv1.SandboxPolicy) *types.SandboxPolicy { } } } + if mw := p.GetNetworkMiddlewares(); mw != nil { + result.NetworkMiddlewares = make(map[string]types.NetworkMiddlewareConfig, len(mw)) + for k, v := range mw { + if v != nil { + result.NetworkMiddlewares[k] = middlewareConfigFromProto(v) + } + } + } return result } @@ -138,6 +149,75 @@ func SandboxPolicyToProto(p *types.SandboxPolicy) *sbv1.SandboxPolicy { result.NetworkPolicies[k] = NetworkPolicyRuleToProto(&v) } } + if p.NetworkMiddlewares != nil { + result.NetworkMiddlewares = make(map[string]*sbv1.NetworkMiddlewareConfig, len(p.NetworkMiddlewares)) + for k, v := range p.NetworkMiddlewares { + result.NetworkMiddlewares[k] = middlewareConfigToProto(&v) + } + } + return result +} + +// SandboxPolicyToProtoChecked converts middleware configuration without +// silently discarding values unsupported by protobuf Struct. +func SandboxPolicyToProtoChecked(p *types.SandboxPolicy) (*sbv1.SandboxPolicy, error) { + result := SandboxPolicyToProto(p) + if p == nil { + return result, nil + } + for name, middleware := range p.NetworkMiddlewares { + if middleware.Config == nil { + continue + } + config, err := structpb.NewStruct(middleware.Config) + if err != nil { + return nil, fmt.Errorf("network middleware %q config: %w", name, err) + } + result.NetworkMiddlewares[name].Config = config + } + return result, nil +} + +func middlewareConfigFromProto(m *sbv1.NetworkMiddlewareConfig) types.NetworkMiddlewareConfig { + result := types.NetworkMiddlewareConfig{ + Name: m.GetName(), + Middleware: m.GetMiddleware(), + OnError: m.GetOnError(), + Order: m.GetOrder(), + } + if c := m.GetConfig(); c != nil { + result.Config = c.AsMap() + } + if ep := m.GetEndpoints(); ep != nil { + result.Endpoints = &types.MiddlewareEndpointSelector{ + Include: CopyStringSlice(ep.GetInclude()), + Exclude: CopyStringSlice(ep.GetExclude()), + } + } + return result +} + +func middlewareConfigToProto(m *types.NetworkMiddlewareConfig) *sbv1.NetworkMiddlewareConfig { + result := &sbv1.NetworkMiddlewareConfig{ + Name: m.Name, + Middleware: m.Middleware, + OnError: m.OnError, + Order: m.Order, + } + if m.Config != nil { + // Non-JSON-compatible values (e.g., chan, func) are silently dropped. + // Round-trip data from structpb.AsMap is always re-serializable. + s, err := structpb.NewStruct(m.Config) + if err == nil { + result.Config = s + } + } + if m.Endpoints != nil { + result.Endpoints = &sbv1.MiddlewareEndpointSelector{ + Include: CopyStringSlice(m.Endpoints.Include), + Exclude: CopyStringSlice(m.Endpoints.Exclude), + } + } return result } @@ -216,6 +296,7 @@ func SandboxPolicyRevisionFromProto(r *pb.SandboxPolicyRevision) *types.SandboxP CreatedAt: TimeFromMillis(r.GetCreatedAtMs()), LoadedAt: TimeFromMillis(r.GetLoadedAtMs()), Policy: SandboxPolicyFromProto(r.GetPolicy()), + Provenance: CopyStringMap(r.GetProvenance()), } } diff --git a/sdk/go/openshell/v1/internal/converter/policy_test.go b/sdk/go/openshell/v1/internal/converter/policy_test.go new file mode 100644 index 0000000000..6935b3def0 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/policy_test.go @@ -0,0 +1,709 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/structpb" +) + +// --- PolicyLoadStatus --- + +func TestPolicyLoadStatusFromProto(t *testing.T) { + tests := []struct { + proto pb.PolicyStatus + want v1.PolicyLoadStatus + }{ + {pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED, v1.PolicyLoadStatusUnspecified}, + {pb.PolicyStatus_POLICY_STATUS_PENDING, v1.PolicyLoadStatusPending}, + {pb.PolicyStatus_POLICY_STATUS_LOADED, v1.PolicyLoadStatusLoaded}, + {pb.PolicyStatus_POLICY_STATUS_FAILED, v1.PolicyLoadStatusFailed}, + {pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, v1.PolicyLoadStatusSuperseded}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicyLoadStatusFromProto(tt.proto)) + }) + } +} + +func TestPolicyLoadStatusToProto(t *testing.T) { + tests := []struct { + sdk v1.PolicyLoadStatus + want pb.PolicyStatus + }{ + {v1.PolicyLoadStatusUnspecified, pb.PolicyStatus_POLICY_STATUS_UNSPECIFIED}, + {v1.PolicyLoadStatusPending, pb.PolicyStatus_POLICY_STATUS_PENDING}, + {v1.PolicyLoadStatusLoaded, pb.PolicyStatus_POLICY_STATUS_LOADED}, + {v1.PolicyLoadStatusFailed, pb.PolicyStatus_POLICY_STATUS_FAILED}, + {v1.PolicyLoadStatusSuperseded, pb.PolicyStatus_POLICY_STATUS_SUPERSEDED}, + } + for _, tt := range tests { + t.Run(tt.sdk.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicyLoadStatusToProto(tt.sdk)) + }) + } +} + +func TestPolicyLoadStatusRoundTrip(t *testing.T) { + for _, s := range []v1.PolicyLoadStatus{ + v1.PolicyLoadStatusUnspecified, + v1.PolicyLoadStatusPending, + v1.PolicyLoadStatusLoaded, + v1.PolicyLoadStatusFailed, + v1.PolicyLoadStatusSuperseded, + } { + assert.Equal(t, s, PolicyLoadStatusFromProto(PolicyLoadStatusToProto(s))) + } +} + +// --- PolicyChunk --- + +func TestPolicyChunkFromProto(t *testing.T) { + proto := &pb.PolicyChunk{ + Id: "chunk-1", + Status: "pending", + RuleName: "web-api", + Rationale: "Observed DNS resolution", + SecurityNotes: "No concerns", + Confidence: 0.95, + DenialSummaryIds: []string{"d1", "d2"}, + CreatedAtMs: 1700000000000, + DecidedAtMs: 1700000001000, + Stage: "initial", + SupersedesChunkId: "chunk-0", + HitCount: 5, + FirstSeenMs: 1699999999000, + LastSeenMs: 1700000000500, + Binary: "/usr/bin/curl", + ValidationResult: "valid", + RejectionReason: "", + ProposedRule: &sbv1.NetworkPolicyRule{ + Name: "web-api", + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + } + + chunk := PolicyChunkFromProto(proto) + + require.NotNil(t, chunk) + assert.Equal(t, "chunk-1", chunk.ID) + assert.Equal(t, "pending", chunk.Status) + assert.Equal(t, "web-api", chunk.RuleName) + assert.Equal(t, "Observed DNS resolution", chunk.Rationale) + assert.Equal(t, "No concerns", chunk.SecurityNotes) + assert.InDelta(t, float32(0.95), chunk.Confidence, 0.001) + assert.Equal(t, []string{"d1", "d2"}, chunk.DenialSummaryIDs) + assert.False(t, chunk.CreatedAt.IsZero()) + assert.False(t, chunk.DecidedAt.IsZero()) + assert.Equal(t, "initial", chunk.Stage) + assert.Equal(t, "chunk-0", chunk.SupersedesChunkID) + assert.Equal(t, int32(5), chunk.HitCount) + assert.False(t, chunk.FirstSeen.IsZero()) + assert.False(t, chunk.LastSeen.IsZero()) + assert.Equal(t, "/usr/bin/curl", chunk.Binary) + assert.Equal(t, "valid", chunk.ValidationResult) + assert.Empty(t, chunk.RejectionReason) + + require.NotNil(t, chunk.ProposedRule) + assert.Equal(t, "web-api", chunk.ProposedRule.Name) + require.Len(t, chunk.ProposedRule.Endpoints, 1) + assert.Equal(t, "api.example.com", chunk.ProposedRule.Endpoints[0].Host) +} + +func TestPolicyChunkFromProto_Nil(t *testing.T) { + assert.Nil(t, PolicyChunkFromProto(nil)) +} + +func TestPolicyChunkDeepCopy(t *testing.T) { + proto := &pb.PolicyChunk{ + Id: "c1", + DenialSummaryIds: []string{"d1"}, + } + + chunk := PolicyChunkFromProto(proto) + proto.DenialSummaryIds[0] = "changed" + + assert.Equal(t, "d1", chunk.DenialSummaryIDs[0]) +} + +// --- DraftPolicy --- + +func TestDraftPolicyFromProto(t *testing.T) { + proto := &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + {Id: "c1", Status: "pending", RuleName: "rule1"}, + {Id: "c2", Status: "approved", RuleName: "rule2"}, + }, + RollingSummary: "Analysis summary", + DraftVersion: 42, + LastAnalyzedAtMs: 1700000000000, + } + + draft := DraftPolicyFromProto(proto) + + require.NotNil(t, draft) + assert.Len(t, draft.Chunks, 2) + assert.Equal(t, "c1", draft.Chunks[0].ID) + assert.Equal(t, "c2", draft.Chunks[1].ID) + assert.Equal(t, "Analysis summary", draft.RollingSummary) + assert.Equal(t, uint64(42), draft.DraftVersion) + assert.False(t, draft.LastAnalyzedAt.IsZero()) +} + +func TestDraftPolicyFromProto_Nil(t *testing.T) { + assert.Nil(t, DraftPolicyFromProto(nil)) +} + +func TestDraftPolicyFromProto_EmptyChunks(t *testing.T) { + proto := &pb.GetDraftPolicyResponse{ + RollingSummary: "empty", + DraftVersion: 1, + } + + draft := DraftPolicyFromProto(proto) + require.NotNil(t, draft) + assert.Empty(t, draft.Chunks) +} + +// --- SandboxPolicy --- + +func TestSandboxPolicyFromProtoNil(t *testing.T) { + assert.Nil(t, SandboxPolicyFromProto(nil)) +} + +func TestSandboxPolicyToProtoNil(t *testing.T) { + assert.Nil(t, SandboxPolicyToProto(nil)) +} + +func TestSandboxPolicyRoundTrip(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 5, + Filesystem: &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/share"}, + ReadWrite: []string{"/tmp", "/workspace"}, + }, + Landlock: &v1.LandlockPolicy{ + Compatibility: "best_effort", + }, + Process: &v1.ProcessPolicy{ + RunAsUser: "sandbox-user", + RunAsGroup: "sandbox-group", + }, + NetworkPolicies: map[string]v1.NetworkPolicyRule{ + "web-api": { + Name: "web-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "rest"}, + }, + }, + "db": { + Name: "db", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "db.internal", Port: 5432, Protocol: "tcp"}, + }, + }, + }, + } + + proto := SandboxPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := SandboxPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Version, roundTrip.Version) + + // Filesystem + require.NotNil(t, roundTrip.Filesystem) + assert.Equal(t, original.Filesystem.IncludeWorkdir, roundTrip.Filesystem.IncludeWorkdir) + assert.Equal(t, original.Filesystem.ReadOnly, roundTrip.Filesystem.ReadOnly) + assert.Equal(t, original.Filesystem.ReadWrite, roundTrip.Filesystem.ReadWrite) + + // Landlock + require.NotNil(t, roundTrip.Landlock) + assert.Equal(t, original.Landlock.Compatibility, roundTrip.Landlock.Compatibility) + + // Process + require.NotNil(t, roundTrip.Process) + assert.Equal(t, original.Process.RunAsUser, roundTrip.Process.RunAsUser) + assert.Equal(t, original.Process.RunAsGroup, roundTrip.Process.RunAsGroup) + + // NetworkPolicies + require.Len(t, roundTrip.NetworkPolicies, 2) + webAPI, ok := roundTrip.NetworkPolicies["web-api"] + require.True(t, ok) + assert.Equal(t, "web-api", webAPI.Name) + require.Len(t, webAPI.Endpoints, 1) + assert.Equal(t, "api.example.com", webAPI.Endpoints[0].Host) + + db, ok := roundTrip.NetworkPolicies["db"] + require.True(t, ok) + assert.Equal(t, "db", db.Name) +} + +func TestSandboxPolicyDeepCopy(t *testing.T) { + // Build a proto, convert to SDK, mutate proto, verify SDK is isolated. + proto := &sbv1.SandboxPolicy{ + Version: 1, + Filesystem: &sbv1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/original"}, + ReadWrite: []string{"/tmp"}, + }, + NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{ + "rule1": { + Name: "rule1", + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "original.host", Port: 80}, + }, + }, + }, + } + + sdk := SandboxPolicyFromProto(proto) + require.NotNil(t, sdk) + + // Mutate proto source after conversion. + proto.Version = 99 + proto.Filesystem.ReadOnly[0] = "mutated" + proto.Filesystem.ReadWrite[0] = "mutated" + proto.NetworkPolicies["rule1"].Name = "mutated" + proto.NetworkPolicies["rule1"].Endpoints[0].Host = "mutated.host" + + // SDK values must be unaffected. + assert.Equal(t, uint32(1), sdk.Version) + assert.Equal(t, "/original", sdk.Filesystem.ReadOnly[0]) + assert.Equal(t, "/tmp", sdk.Filesystem.ReadWrite[0]) + assert.Equal(t, "rule1", sdk.NetworkPolicies["rule1"].Name) + assert.Equal(t, "original.host", sdk.NetworkPolicies["rule1"].Endpoints[0].Host) + + // Also test ToProto deep-copy isolation. + protoOut := SandboxPolicyToProto(sdk) + require.NotNil(t, protoOut) + + // Mutate SDK after ToProto conversion. + sdk.Filesystem.ReadOnly[0] = "sdk-mutated" + + // Proto output must be unaffected. + assert.Equal(t, "/original", protoOut.Filesystem.ReadOnly[0]) +} + +func TestSandboxPolicyPartialSubPolicies(t *testing.T) { + t.Run("only filesystem", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 1, + Filesystem: &v1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + require.NotNil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + assert.Nil(t, roundTrip.Process) + assert.Nil(t, roundTrip.NetworkPolicies) + }) + + t.Run("only landlock", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 2, + Landlock: &v1.LandlockPolicy{ + Compatibility: "hard_requirement", + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + require.NotNil(t, roundTrip.Landlock) + assert.Equal(t, "hard_requirement", roundTrip.Landlock.Compatibility) + assert.Nil(t, roundTrip.Process) + assert.Nil(t, roundTrip.NetworkPolicies) + }) + + t.Run("only process", func(t *testing.T) { + original := &v1.SandboxPolicy{ + Process: &v1.ProcessPolicy{ + RunAsUser: "nobody", + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + require.NotNil(t, roundTrip.Process) + assert.Equal(t, "nobody", roundTrip.Process.RunAsUser) + }) + + t.Run("only network policies", func(t *testing.T) { + original := &v1.SandboxPolicy{ + NetworkPolicies: map[string]v1.NetworkPolicyRule{ + "r1": {Name: "r1"}, + }, + } + roundTrip := SandboxPolicyFromProto(SandboxPolicyToProto(original)) + require.NotNil(t, roundTrip) + assert.Nil(t, roundTrip.Filesystem) + assert.Nil(t, roundTrip.Landlock) + assert.Nil(t, roundTrip.Process) + require.Len(t, roundTrip.NetworkPolicies, 1) + }) + + t.Run("empty network policies map preserved", func(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + NetworkPolicies: map[string]*sbv1.NetworkPolicyRule{}, + } + // Proto empty map is non-nil, so converter creates an empty SDK map. + sdk := SandboxPolicyFromProto(proto) + require.NotNil(t, sdk) + require.NotNil(t, sdk.NetworkPolicies) + assert.Empty(t, sdk.NetworkPolicies) + }) +} + +func TestFilesystemPolicyRoundTrip(t *testing.T) { + original := &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/etc", "/usr/lib"}, + ReadWrite: []string{"/tmp", "/var/run"}, + } + + proto := filesystemPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := filesystemPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.IncludeWorkdir, roundTrip.IncludeWorkdir) + assert.Equal(t, original.ReadOnly, roundTrip.ReadOnly) + assert.Equal(t, original.ReadWrite, roundTrip.ReadWrite) +} + +func TestFilesystemPolicyNil(t *testing.T) { + assert.Nil(t, filesystemPolicyFromProto(nil)) + assert.Nil(t, filesystemPolicyToProto(nil)) +} + +func TestLandlockPolicyRoundTrip(t *testing.T) { + original := &v1.LandlockPolicy{ + Compatibility: "best_effort", + } + + proto := landlockPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := landlockPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.Compatibility, roundTrip.Compatibility) +} + +func TestLandlockPolicyNil(t *testing.T) { + assert.Nil(t, landlockPolicyFromProto(nil)) + assert.Nil(t, landlockPolicyToProto(nil)) +} + +func TestProcessPolicyRoundTrip(t *testing.T) { + original := &v1.ProcessPolicy{ + RunAsUser: "app-user", + RunAsGroup: "app-group", + } + + proto := processPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := processPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + assert.Equal(t, original.RunAsUser, roundTrip.RunAsUser) + assert.Equal(t, original.RunAsGroup, roundTrip.RunAsGroup) +} + +func TestProcessPolicyNil(t *testing.T) { + assert.Nil(t, processPolicyFromProto(nil)) + assert.Nil(t, processPolicyToProto(nil)) +} + +// --- SandboxPolicyRevision --- + +func TestSandboxPolicyRevisionFromProto(t *testing.T) { + proto := &pb.SandboxPolicyRevision{ + Version: 3, + PolicyHash: "sha256:abc123", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + LoadError: "", + CreatedAtMs: 1700000000000, + LoadedAtMs: 1700000001000, + Provenance: map[string]string{"source": "api", "user": "admin"}, + } + + rev := SandboxPolicyRevisionFromProto(proto) + + require.NotNil(t, rev) + assert.Equal(t, uint32(3), rev.Version) + assert.Equal(t, "sha256:abc123", rev.PolicyHash) + assert.Equal(t, v1.PolicyLoadStatusLoaded, rev.Status) + assert.Empty(t, rev.LoadError) + assert.False(t, rev.CreatedAt.IsZero()) + assert.False(t, rev.LoadedAt.IsZero()) + assert.Equal(t, map[string]string{"source": "api", "user": "admin"}, rev.Provenance) + + proto.Provenance["source"] = "MUTATED" + assert.Equal(t, "api", rev.Provenance["source"], "provenance must be deep copied") +} + +func TestSandboxPolicyRevisionFromProto_Nil(t *testing.T) { + assert.Nil(t, SandboxPolicyRevisionFromProto(nil)) +} + +func TestSandboxPolicyRevisionFromProto_WithPolicy(t *testing.T) { + proto := &pb.SandboxPolicyRevision{ + Version: 1, + PolicyHash: "sha256:def", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + Policy: &sbv1.SandboxPolicy{ + Version: 2, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + } + + rev := SandboxPolicyRevisionFromProto(proto) + require.NotNil(t, rev) + require.NotNil(t, rev.Policy, "typed SandboxPolicy should be populated when proto policy is set") + assert.Equal(t, uint32(2), rev.Policy.Version) + require.NotNil(t, rev.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, rev.Policy.Filesystem.ReadOnly) +} + +// --- PolicyStatusResult --- + +func TestPolicyStatusResultFromProto(t *testing.T) { + proto := &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 5, + PolicyHash: "sha256:xyz", + Status: pb.PolicyStatus_POLICY_STATUS_PENDING, + }, + ActiveVersion: 4, + } + + result := PolicyStatusResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(5), result.Revision.Version) + assert.Equal(t, "sha256:xyz", result.Revision.PolicyHash) + assert.Equal(t, v1.PolicyLoadStatusPending, result.Revision.Status) + assert.Equal(t, uint32(4), result.ActiveVersion) +} + +func TestPolicyStatusResultFromProto_Nil(t *testing.T) { + assert.Nil(t, PolicyStatusResultFromProto(nil)) +} + +// --- ApproveResult --- + +func TestApproveResultFromProto(t *testing.T) { + proto := &pb.ApproveDraftChunkResponse{ + PolicyVersion: 7, + PolicyHash: "sha256:merged", + } + + result := ApproveResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(7), result.PolicyVersion) + assert.Equal(t, "sha256:merged", result.PolicyHash) +} + +func TestApproveResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ApproveResultFromProto(nil)) +} + +// --- ApproveAllResult --- + +func TestApproveAllResultFromProto(t *testing.T) { + proto := &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 8, + PolicyHash: "sha256:all", + ChunksApproved: 10, + ChunksSkipped: 2, + } + + result := ApproveAllResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(8), result.PolicyVersion) + assert.Equal(t, "sha256:all", result.PolicyHash) + assert.Equal(t, uint32(10), result.ChunksApproved) + assert.Equal(t, uint32(2), result.ChunksSkipped) +} + +func TestApproveAllResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ApproveAllResultFromProto(nil)) +} + +// --- UndoResult --- + +func TestUndoResultFromProto(t *testing.T) { + proto := &pb.UndoDraftChunkResponse{ + PolicyVersion: 6, + PolicyHash: "sha256:reverted", + } + + result := UndoResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(6), result.PolicyVersion) + assert.Equal(t, "sha256:reverted", result.PolicyHash) +} + +func TestUndoResultFromProto_Nil(t *testing.T) { + assert.Nil(t, UndoResultFromProto(nil)) +} + +// --- ClearResult --- + +func TestClearResultFromProto(t *testing.T) { + proto := &pb.ClearDraftChunksResponse{ + ChunksCleared: 15, + } + + result := ClearResultFromProto(proto) + + require.NotNil(t, result) + assert.Equal(t, uint32(15), result.ChunksCleared) +} + +func TestClearResultFromProto_Nil(t *testing.T) { + assert.Nil(t, ClearResultFromProto(nil)) +} + +// --- DraftHistoryEntry --- + +func TestDraftHistoryEntryFromProto(t *testing.T) { + proto := &pb.DraftHistoryEntry{ + TimestampMs: 1700000000000, + EventType: "approved", + Description: "Chunk c1 approved", + ChunkId: "c1", + } + + entry := DraftHistoryEntryFromProto(proto) + + require.NotNil(t, entry) + assert.False(t, entry.Timestamp.IsZero()) + assert.Equal(t, "approved", entry.EventType) + assert.Equal(t, "Chunk c1 approved", entry.Description) + assert.Equal(t, "c1", entry.ChunkID) +} + +func TestDraftHistoryEntryFromProto_Nil(t *testing.T) { + assert.Nil(t, DraftHistoryEntryFromProto(nil)) +} + +// --- NetworkMiddleware --- + +func TestSandboxPolicyFromProto_WithMiddleware(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + Version: 3, + NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + "sigv4-rewriter": { + Name: "sigv4-rewriter", + Middleware: "aws-sigv4", + OnError: "fail_closed", + Order: 10, + Config: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{ + "region": "us-east-1", + "service": "bedrock", + }) + return s + }(), + Endpoints: &sbv1.MiddlewareEndpointSelector{ + Include: []string{"*.bedrock.amazonaws.com"}, + Exclude: []string{"sts.amazonaws.com"}, + }, + }, + }, + } + + policy := SandboxPolicyFromProto(proto) + + require.NotNil(t, policy) + require.Contains(t, policy.NetworkMiddlewares, "sigv4-rewriter") + mw := policy.NetworkMiddlewares["sigv4-rewriter"] + assert.Equal(t, "sigv4-rewriter", mw.Name) + assert.Equal(t, "aws-sigv4", mw.Middleware) + assert.Equal(t, "fail_closed", mw.OnError) + assert.Equal(t, int32(10), mw.Order) + require.NotNil(t, mw.Config) + assert.Equal(t, "us-east-1", mw.Config["region"]) + assert.Equal(t, "bedrock", mw.Config["service"]) + require.NotNil(t, mw.Endpoints) + assert.Equal(t, []string{"*.bedrock.amazonaws.com"}, mw.Endpoints.Include) + assert.Equal(t, []string{"sts.amazonaws.com"}, mw.Endpoints.Exclude) +} + +func TestSandboxPolicyMiddlewareRoundTrip(t *testing.T) { + original := &v1.SandboxPolicy{ + Version: 5, + NetworkMiddlewares: map[string]v1.NetworkMiddlewareConfig{ + "rate-limiter": { + Name: "rate-limiter", + Middleware: "envoy-ratelimit", + OnError: "fail_open", + Order: 20, + Config: map[string]any{ + "requests_per_second": float64(100), + }, + Endpoints: &v1.MiddlewareEndpointSelector{ + Include: []string{"api.*"}, + }, + }, + }, + } + + proto := SandboxPolicyToProto(original) + require.NotNil(t, proto) + + roundTrip := SandboxPolicyFromProto(proto) + require.NotNil(t, roundTrip) + + require.Contains(t, roundTrip.NetworkMiddlewares, "rate-limiter") + mw := roundTrip.NetworkMiddlewares["rate-limiter"] + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Name, mw.Name) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Middleware, mw.Middleware) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].OnError, mw.OnError) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Order, mw.Order) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Config["requests_per_second"], mw.Config["requests_per_second"]) + assert.Equal(t, original.NetworkMiddlewares["rate-limiter"].Endpoints.Include, mw.Endpoints.Include) +} + +func TestSandboxPolicyMiddlewareDeepCopy(t *testing.T) { + proto := &sbv1.SandboxPolicy{ + NetworkMiddlewares: map[string]*sbv1.NetworkMiddlewareConfig{ + "test": { + Endpoints: &sbv1.MiddlewareEndpointSelector{ + Include: []string{"original.com"}, + }, + }, + }, + } + + policy := SandboxPolicyFromProto(proto) + proto.NetworkMiddlewares["test"].Endpoints.Include[0] = "mutated.com" + + assert.Equal(t, "original.com", policy.NetworkMiddlewares["test"].Endpoints.Include[0]) +} diff --git a/sdk/go/openshell/v1/internal/converter/profile.go b/sdk/go/openshell/v1/internal/converter/profile.go new file mode 100644 index 0000000000..b0edaf6445 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/profile.go @@ -0,0 +1,409 @@ +// 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" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- ProfileCategory enum mapping --- + +// ProfileCategoryFromProto converts a proto ProviderProfileCategory to an SDK ProfileCategory. +func ProfileCategoryFromProto(c pb.ProviderProfileCategory) types.ProfileCategory { + switch c { + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER: + return types.ProfileCategoryOther + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE: + return types.ProfileCategoryInference + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT: + return types.ProfileCategoryAgent + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL: + return types.ProfileCategorySourceControl + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING: + return types.ProfileCategoryMessaging + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA: + return types.ProfileCategoryData + case pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE: + return types.ProfileCategoryKnowledge + default: + return types.ProfileCategory("") + } +} + +// ProfileCategoryToProto converts an SDK ProfileCategory to a proto ProviderProfileCategory. +func ProfileCategoryToProto(c types.ProfileCategory) pb.ProviderProfileCategory { + switch c { + case types.ProfileCategoryOther: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER + case types.ProfileCategoryInference: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE + case types.ProfileCategoryAgent: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT + case types.ProfileCategorySourceControl: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL + case types.ProfileCategoryMessaging: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING + case types.ProfileCategoryData: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA + case types.ProfileCategoryKnowledge: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE + default: + return pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED + } +} + +// --- NetworkEndpoint --- + +// NetworkEndpointFromProto converts a proto NetworkEndpoint to an SDK NetworkEndpoint. +// Only Host, Port, and Protocol are mapped; additional proto fields are ignored. +func NetworkEndpointFromProto(ep *sbv1.NetworkEndpoint) *types.NetworkEndpoint { + if ep == nil { + return nil + } + return &types.NetworkEndpoint{ + Host: ep.GetHost(), + Port: ep.GetPort(), + Protocol: ep.GetProtocol(), + } +} + +// NetworkEndpointToProto converts an SDK NetworkEndpoint to a proto NetworkEndpoint. +func NetworkEndpointToProto(ep *types.NetworkEndpoint) *sbv1.NetworkEndpoint { + if ep == nil { + return nil + } + return &sbv1.NetworkEndpoint{ + Host: ep.Host, + Port: ep.Port, + Protocol: ep.Protocol, + } +} + +// --- NetworkBinary --- + +// NetworkBinaryFromProto converts a proto NetworkBinary to an SDK NetworkBinary. +func NetworkBinaryFromProto(b *sbv1.NetworkBinary) *types.NetworkBinary { + if b == nil { + return nil + } + return &types.NetworkBinary{ + Path: b.GetPath(), + } +} + +// NetworkBinaryToProto converts an SDK NetworkBinary to a proto NetworkBinary. +func NetworkBinaryToProto(b *types.NetworkBinary) *sbv1.NetworkBinary { + if b == nil { + return nil + } + return &sbv1.NetworkBinary{ + Path: b.Path, + } +} + +// --- ProfileCredential --- + +// ProfileCredentialFromProto converts a proto ProviderProfileCredential to an SDK ProfileCredential. +// Secret is derived from whether the proto has a Refresh configuration. +func ProfileCredentialFromProto(c *pb.ProviderProfileCredential) *types.ProfileCredential { + if c == nil { + return nil + } + return &types.ProfileCredential{ + Name: c.GetName(), + Description: c.GetDescription(), + EnvVars: CopyStringSlice(c.GetEnvVars()), + Required: c.GetRequired(), + Secret: c.GetRefresh() != nil, + Refresh: profileCredentialRefreshFromProto(c.GetRefresh()), + AuthStyle: c.GetAuthStyle(), + HeaderName: c.GetHeaderName(), + QueryParam: c.GetQueryParam(), + PathTemplate: c.GetPathTemplate(), + TokenGrant: tokenGrantFromProto(c.GetTokenGrant()), + } +} + +// ProfileCredentialToProto converts an SDK ProfileCredential to a proto ProviderProfileCredential. +func ProfileCredentialToProto(c *types.ProfileCredential) *pb.ProviderProfileCredential { + if c == nil { + return nil + } + return &pb.ProviderProfileCredential{ + Name: c.Name, + Description: c.Description, + EnvVars: CopyStringSlice(c.EnvVars), + Required: c.Required, + AuthStyle: c.AuthStyle, + HeaderName: c.HeaderName, + QueryParam: c.QueryParam, + PathTemplate: c.PathTemplate, + Refresh: profileCredentialRefreshToProto(c.Refresh), + TokenGrant: tokenGrantToProto(c.TokenGrant), + } +} + +func profileCredentialRefreshFromProto(r *pb.ProviderCredentialRefresh) *types.ProfileCredentialRefresh { + if r == nil { + return nil + } + result := &types.ProfileCredentialRefresh{ + Strategy: RefreshStrategyFromProto(r.GetStrategy()), TokenURL: r.GetTokenUrl(), + Scopes: CopyStringSlice(r.GetScopes()), RefreshBeforeSeconds: r.GetRefreshBeforeSeconds(), + MaxLifetimeSeconds: r.GetMaxLifetimeSeconds(), + } + for _, material := range r.GetMaterial() { + result.Material = append(result.Material, types.ProfileCredentialRefreshMaterial{Name: material.GetName(), Description: material.GetDescription(), Required: material.GetRequired(), Secret: material.GetSecret()}) + } + for _, output := range r.GetAdditionalOutputs() { + result.AdditionalOutputs = append(result.AdditionalOutputs, types.ProfileCredentialRefreshOutput{Output: output.GetOutput(), Credential: output.GetCredential()}) + } + return result +} + +func profileCredentialRefreshToProto(r *types.ProfileCredentialRefresh) *pb.ProviderCredentialRefresh { + if r == nil { + return nil + } + result := &pb.ProviderCredentialRefresh{ + Strategy: RefreshStrategyToProto(r.Strategy), TokenUrl: r.TokenURL, + Scopes: CopyStringSlice(r.Scopes), RefreshBeforeSeconds: r.RefreshBeforeSeconds, + MaxLifetimeSeconds: r.MaxLifetimeSeconds, + } + for _, material := range r.Material { + result.Material = append(result.Material, &pb.ProviderCredentialRefreshMaterial{Name: material.Name, Description: material.Description, Required: material.Required, Secret: material.Secret}) + } + for _, output := range r.AdditionalOutputs { + result.AdditionalOutputs = append(result.AdditionalOutputs, &pb.ProviderCredentialRefreshOutput{Output: output.Output, Credential: output.Credential}) + } + return result +} + +func tokenGrantFromProto(tg *pb.ProviderCredentialTokenGrant) *types.CredentialTokenGrant { + if tg == nil { + return nil + } + result := &types.CredentialTokenGrant{ + TokenEndpoint: tg.GetTokenEndpoint(), + Audience: tg.GetAudience(), + JWTSVIDAudience: tg.GetJwtSvidAudience(), + Scopes: CopyStringSlice(tg.GetScopes()), + CacheTTLSeconds: tg.GetCacheTtlSeconds(), + ClientAssertionType: tg.GetClientAssertionType(), + } + if overrides := tg.GetAudienceOverrides(); len(overrides) > 0 { + result.AudienceOverrides = make([]types.TokenGrantAudienceOverride, len(overrides)) + for i, o := range overrides { + result.AudienceOverrides[i] = audienceOverrideFromProto(o) + } + } + return result +} + +func tokenGrantToProto(tg *types.CredentialTokenGrant) *pb.ProviderCredentialTokenGrant { + if tg == nil { + return nil + } + result := &pb.ProviderCredentialTokenGrant{ + TokenEndpoint: tg.TokenEndpoint, + Audience: tg.Audience, + JwtSvidAudience: tg.JWTSVIDAudience, + Scopes: CopyStringSlice(tg.Scopes), + CacheTtlSeconds: tg.CacheTTLSeconds, + ClientAssertionType: tg.ClientAssertionType, + } + if len(tg.AudienceOverrides) > 0 { + result.AudienceOverrides = make([]*pb.ProviderCredentialTokenGrantAudienceOverride, len(tg.AudienceOverrides)) + for i := range tg.AudienceOverrides { + result.AudienceOverrides[i] = audienceOverrideToProto(&tg.AudienceOverrides[i]) + } + } + return result +} + +func audienceOverrideFromProto(o *pb.ProviderCredentialTokenGrantAudienceOverride) types.TokenGrantAudienceOverride { + if o == nil { + return types.TokenGrantAudienceOverride{} + } + return types.TokenGrantAudienceOverride{ + Host: o.GetHost(), + Port: o.GetPort(), + Path: o.GetPath(), + Audience: o.GetAudience(), + Scopes: CopyStringSlice(o.GetScopes()), + } +} + +func audienceOverrideToProto(o *types.TokenGrantAudienceOverride) *pb.ProviderCredentialTokenGrantAudienceOverride { + if o == nil { + return nil + } + return &pb.ProviderCredentialTokenGrantAudienceOverride{ + Host: o.Host, + Port: o.Port, + Path: o.Path, + Audience: o.Audience, + Scopes: CopyStringSlice(o.Scopes), + } +} + +// --- ProfileDiagnostic --- + +// ProfileDiagnosticFromProto converts a proto ProviderProfileDiagnostic to an SDK ProfileDiagnostic. +func ProfileDiagnosticFromProto(d *pb.ProviderProfileDiagnostic) *types.ProfileDiagnostic { + if d == nil { + return nil + } + return &types.ProfileDiagnostic{ + Source: d.GetSource(), + ProfileID: d.GetProfileId(), + Field: d.GetField(), + Message: d.GetMessage(), + Severity: d.GetSeverity(), + } +} + +// --- ProviderProfile --- + +// ProviderProfileFromProto converts a proto ProviderProfile to an SDK ProviderProfile. +func ProviderProfileFromProto(p *pb.ProviderProfile) *types.ProviderProfile { + if p == nil { + return nil + } + + result := &types.ProviderProfile{ + ID: p.GetId(), + DisplayName: p.GetDisplayName(), + Description: p.GetDescription(), + Category: ProfileCategoryFromProto(p.GetCategory()), + InferenceCapable: p.GetInferenceCapable(), + ResourceVersion: p.GetResourceVersion(), + Annotations: CopyStringMap(p.GetAnnotations()), + Source: p.GetSource(), + Scope: p.GetScope(), + } + + // Credentials + if creds := p.GetCredentials(); len(creds) > 0 { + result.Credentials = make([]types.ProfileCredential, len(creds)) + for i, c := range creds { + if converted := ProfileCredentialFromProto(c); converted != nil { + result.Credentials[i] = *converted + } + } + } + + // Endpoints + if eps := p.GetEndpoints(); len(eps) > 0 { + result.Endpoints = make([]types.NetworkEndpoint, len(eps)) + for i, ep := range eps { + if converted := NetworkEndpointFromProto(ep); converted != nil { + result.Endpoints[i] = *converted + } + } + } + + // Binaries + if bins := p.GetBinaries(); len(bins) > 0 { + result.Binaries = make([]types.NetworkBinary, len(bins)) + for i, b := range bins { + if converted := NetworkBinaryFromProto(b); converted != nil { + result.Binaries[i] = *converted + } + } + } + + // Discovery + if d := p.GetDiscovery(); d != nil { + result.Discovery = types.ProfileDiscovery{ + Credentials: CopyStringSlice(d.GetCredentials()), + } + } + + return result +} + +// ProviderProfileToProto converts an SDK ProviderProfile to a proto ProviderProfile. +func ProviderProfileToProto(p *types.ProviderProfile) *pb.ProviderProfile { + if p == nil { + return nil + } + + result := &pb.ProviderProfile{ + Id: p.ID, + DisplayName: p.DisplayName, + Description: p.Description, + Category: ProfileCategoryToProto(p.Category), + InferenceCapable: p.InferenceCapable, + ResourceVersion: p.ResourceVersion, + Annotations: CopyStringMap(p.Annotations), + Source: p.Source, + Scope: p.Scope, + } + + // Credentials + if len(p.Credentials) > 0 { + result.Credentials = make([]*pb.ProviderProfileCredential, len(p.Credentials)) + for i := range p.Credentials { + result.Credentials[i] = ProfileCredentialToProto(&p.Credentials[i]) + } + } + + // Endpoints + if len(p.Endpoints) > 0 { + result.Endpoints = make([]*sbv1.NetworkEndpoint, len(p.Endpoints)) + for i := range p.Endpoints { + result.Endpoints[i] = NetworkEndpointToProto(&p.Endpoints[i]) + } + } + + // Binaries + if len(p.Binaries) > 0 { + result.Binaries = make([]*sbv1.NetworkBinary, len(p.Binaries)) + for i := range p.Binaries { + result.Binaries[i] = NetworkBinaryToProto(&p.Binaries[i]) + } + } + + // Discovery + if len(p.Discovery.Credentials) > 0 { + result.Discovery = &pb.ProviderProfileDiscovery{ + Credentials: CopyStringSlice(p.Discovery.Credentials), + } + } + + return result +} + +// --- ProfileImportItem --- + +// ProfileImportItemToProto converts an SDK ProfileImportItem to a proto ProviderProfileImportItem. +func ProfileImportItemToProto(item *types.ProfileImportItem) *pb.ProviderProfileImportItem { + if item == nil { + return nil + } + return &pb.ProviderProfileImportItem{ + Profile: ProviderProfileToProto(&item.Profile), + Source: item.Source, + } +} + +// ProfileImportItemFromProto converts a proto ProviderProfileImportItem to an SDK ProfileImportItem. +func ProfileImportItemFromProto(item *pb.ProviderProfileImportItem) *types.ProfileImportItem { + if item == nil { + return nil + } + + result := &types.ProfileImportItem{ + Source: item.GetSource(), + } + + if p := ProviderProfileFromProto(item.GetProfile()); p != nil { + result.Profile = *p + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/profile_test.go b/sdk/go/openshell/v1/internal/converter/profile_test.go new file mode 100644 index 0000000000..8efe8d294b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/profile_test.go @@ -0,0 +1,616 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- ProfileCategory --- + +func TestProfileCategoryFromProto(t *testing.T) { + tests := []struct { + proto pb.ProviderProfileCategory + want v1.ProfileCategory + }{ + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER, v1.ProfileCategoryOther}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, v1.ProfileCategoryInference}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT, v1.ProfileCategoryAgent}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL, v1.ProfileCategorySourceControl}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING, v1.ProfileCategoryMessaging}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA, v1.ProfileCategoryData}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE, v1.ProfileCategoryKnowledge}, + {pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED, v1.ProfileCategory("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, ProfileCategoryFromProto(tt.proto)) + }) + } +} + +func TestProfileCategoryToProto(t *testing.T) { + tests := []struct { + sdk v1.ProfileCategory + want pb.ProviderProfileCategory + }{ + {v1.ProfileCategoryOther, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_OTHER}, + {v1.ProfileCategoryInference, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE}, + {v1.ProfileCategoryAgent, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT}, + {v1.ProfileCategorySourceControl, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_SOURCE_CONTROL}, + {v1.ProfileCategoryMessaging, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_MESSAGING}, + {v1.ProfileCategoryData, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_DATA}, + {v1.ProfileCategoryKnowledge, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_KNOWLEDGE}, + {v1.ProfileCategory(""), pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED}, + {v1.ProfileCategory("Unknown"), pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, ProfileCategoryToProto(tt.sdk)) + }) + } +} + +// --- NetworkEndpoint --- + +func TestNetworkEndpointFromProto(t *testing.T) { + proto := &sbv1.NetworkEndpoint{ + Host: "api.example.com", + Port: 443, + Protocol: "rest", + } + + ep := NetworkEndpointFromProto(proto) + + require.NotNil(t, ep) + assert.Equal(t, "api.example.com", ep.Host) + assert.Equal(t, uint32(443), ep.Port) + assert.Equal(t, "rest", ep.Protocol) +} + +func TestNetworkEndpointFromProto_Nil(t *testing.T) { + ep := NetworkEndpointFromProto(nil) + assert.Nil(t, ep) +} + +func TestNetworkEndpointToProto(t *testing.T) { + ep := &v1.NetworkEndpoint{ + Host: "api.example.com", + Port: 443, + Protocol: "rest", + } + + proto := NetworkEndpointToProto(ep) + + require.NotNil(t, proto) + assert.Equal(t, "api.example.com", proto.Host) + assert.Equal(t, uint32(443), proto.Port) + assert.Equal(t, "rest", proto.Protocol) +} + +func TestNetworkEndpointToProto_Nil(t *testing.T) { + proto := NetworkEndpointToProto(nil) + assert.Nil(t, proto) +} + +// --- NetworkBinary --- + +func TestNetworkBinaryFromProto(t *testing.T) { + proto := &sbv1.NetworkBinary{ + Path: "/usr/local/bin/tool", + } + + bin := NetworkBinaryFromProto(proto) + + require.NotNil(t, bin) + assert.Equal(t, "/usr/local/bin/tool", bin.Path) +} + +func TestNetworkBinaryFromProto_Nil(t *testing.T) { + bin := NetworkBinaryFromProto(nil) + assert.Nil(t, bin) +} + +func TestNetworkBinaryToProto(t *testing.T) { + bin := &v1.NetworkBinary{ + Path: "/usr/local/bin/tool", + } + + proto := NetworkBinaryToProto(bin) + + require.NotNil(t, proto) + assert.Equal(t, "/usr/local/bin/tool", proto.Path) +} + +func TestNetworkBinaryToProto_Nil(t *testing.T) { + proto := NetworkBinaryToProto(nil) + assert.Nil(t, proto) +} + +// --- ProfileCredential --- + +func TestProfileCredentialFromProto(t *testing.T) { + proto := &pb.ProviderProfileCredential{ + Name: "API_KEY", + Description: "API key for auth", + EnvVars: []string{"ANTHROPIC_API_KEY", "API_KEY"}, + Required: true, + AuthStyle: "header", + HeaderName: "X-API-Key", + QueryParam: "api_key", + PathTemplate: "/v1/{credential}/chat", + Refresh: &pb.ProviderCredentialRefresh{ + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, + }, + TokenGrant: &pb.ProviderCredentialTokenGrant{ + TokenEndpoint: "https://auth.example.com/token", + Audience: "https://api.example.com", + JwtSvidAudience: "spiffe://example.com", + Scopes: []string{"read", "write"}, + CacheTtlSeconds: 300, + ClientAssertionType: "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", + AudienceOverrides: []*pb.ProviderCredentialTokenGrantAudienceOverride{ + {Host: "special.example.com", Port: 8443, Path: "/api", Audience: "https://special.example.com", Scopes: []string{"admin"}}, + }, + }, + } + + cred := ProfileCredentialFromProto(proto) + + require.NotNil(t, cred) + assert.Equal(t, "API_KEY", cred.Name) + assert.Equal(t, "API key for auth", cred.Description) + assert.Equal(t, []string{"ANTHROPIC_API_KEY", "API_KEY"}, cred.EnvVars) + assert.True(t, cred.Required) + assert.True(t, cred.Secret, "credential with refresh config is secret") + assert.Equal(t, "header", cred.AuthStyle) + assert.Equal(t, "X-API-Key", cred.HeaderName) + assert.Equal(t, "api_key", cred.QueryParam) + assert.Equal(t, "/v1/{credential}/chat", cred.PathTemplate) + + require.NotNil(t, cred.TokenGrant) + assert.Equal(t, "https://auth.example.com/token", cred.TokenGrant.TokenEndpoint) + assert.Equal(t, "https://api.example.com", cred.TokenGrant.Audience) + assert.Equal(t, "spiffe://example.com", cred.TokenGrant.JWTSVIDAudience) + assert.Equal(t, []string{"read", "write"}, cred.TokenGrant.Scopes) + assert.Equal(t, int64(300), cred.TokenGrant.CacheTTLSeconds) + assert.Equal(t, "urn:ietf:params:oauth:client-assertion-type:jwt-bearer", cred.TokenGrant.ClientAssertionType) + require.Len(t, cred.TokenGrant.AudienceOverrides, 1) + assert.Equal(t, "special.example.com", cred.TokenGrant.AudienceOverrides[0].Host) + assert.Equal(t, uint32(8443), cred.TokenGrant.AudienceOverrides[0].Port) + assert.Equal(t, "/api", cred.TokenGrant.AudienceOverrides[0].Path) + assert.Equal(t, "https://special.example.com", cred.TokenGrant.AudienceOverrides[0].Audience) + assert.Equal(t, []string{"admin"}, cred.TokenGrant.AudienceOverrides[0].Scopes) +} + +func TestProfileCredentialFromProto_DeepCopy(t *testing.T) { + proto := &pb.ProviderProfileCredential{ + Name: "KEY", + EnvVars: []string{"ENV_A"}, + TokenGrant: &pb.ProviderCredentialTokenGrant{ + Scopes: []string{"read"}, + AudienceOverrides: []*pb.ProviderCredentialTokenGrantAudienceOverride{ + {Scopes: []string{"admin"}}, + }, + }, + } + + cred := ProfileCredentialFromProto(proto) + + proto.EnvVars[0] = "MUTATED" + assert.Equal(t, "ENV_A", cred.EnvVars[0], "env_vars must be deep copied") + + proto.TokenGrant.Scopes[0] = "MUTATED" + assert.Equal(t, "read", cred.TokenGrant.Scopes[0], "token grant scopes must be deep copied") + + proto.TokenGrant.AudienceOverrides[0].Scopes[0] = "MUTATED" + assert.Equal(t, "admin", cred.TokenGrant.AudienceOverrides[0].Scopes[0], "audience override scopes must be deep copied") +} + +func TestProfileCredentialFromProto_NotSecret(t *testing.T) { + proto := &pb.ProviderProfileCredential{ + Name: "ENDPOINT_URL", + Required: false, + } + + cred := ProfileCredentialFromProto(proto) + + require.NotNil(t, cred) + assert.Equal(t, "ENDPOINT_URL", cred.Name) + assert.False(t, cred.Required) + assert.False(t, cred.Secret, "credential without refresh config is not secret") + assert.Nil(t, cred.TokenGrant) +} + +func TestProfileCredentialFromProto_Nil(t *testing.T) { + cred := ProfileCredentialFromProto(nil) + assert.Nil(t, cred) +} + +func TestProfileCredentialToProto(t *testing.T) { + cred := &v1.ProfileCredential{ + Name: "API_KEY", + Description: "API key", + EnvVars: []string{"ANTHROPIC_API_KEY"}, + Required: true, + Secret: true, + Refresh: &v1.ProfileCredentialRefresh{ + Strategy: v1.RefreshStrategyOAuth2RefreshToken, + TokenURL: "https://auth.example.com/token", + Scopes: []string{"offline_access"}, + RefreshBeforeSeconds: 60, + MaxLifetimeSeconds: 3600, + Material: []v1.ProfileCredentialRefreshMaterial{{Name: "refresh_token", Required: true, Secret: true}}, + AdditionalOutputs: []v1.ProfileCredentialRefreshOutput{{Output: "session_token", Credential: "SESSION_TOKEN"}}, + }, + AuthStyle: "header", + HeaderName: "X-API-Key", + QueryParam: "api_key", + PathTemplate: "/v1/{credential}/chat", + TokenGrant: &v1.CredentialTokenGrant{ + TokenEndpoint: "https://auth.example.com/token", + Audience: "https://api.example.com", + JWTSVIDAudience: "spiffe://example.com", + Scopes: []string{"read"}, + CacheTTLSeconds: 300, + ClientAssertionType: "urn:custom", + AudienceOverrides: []v1.TokenGrantAudienceOverride{ + {Host: "h", Port: 443, Path: "/p", Audience: "aud", Scopes: []string{"s"}}, + }, + }, + } + + proto := ProfileCredentialToProto(cred) + + require.NotNil(t, proto) + assert.Equal(t, "API_KEY", proto.Name) + assert.Equal(t, "API key", proto.Description) + assert.Equal(t, []string{"ANTHROPIC_API_KEY"}, proto.EnvVars) + assert.True(t, proto.Required) + assert.Equal(t, "header", proto.AuthStyle) + assert.Equal(t, "X-API-Key", proto.HeaderName) + assert.Equal(t, "api_key", proto.QueryParam) + assert.Equal(t, "/v1/{credential}/chat", proto.PathTemplate) + require.NotNil(t, proto.Refresh) + assert.Equal(t, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, proto.Refresh.Strategy) + assert.Equal(t, "https://auth.example.com/token", proto.Refresh.TokenUrl) + assert.Equal(t, []string{"offline_access"}, proto.Refresh.Scopes) + require.Len(t, proto.Refresh.Material, 1) + require.Len(t, proto.Refresh.AdditionalOutputs, 1) + + require.NotNil(t, proto.TokenGrant) + assert.Equal(t, "https://auth.example.com/token", proto.TokenGrant.TokenEndpoint) + assert.Equal(t, "https://api.example.com", proto.TokenGrant.Audience) + assert.Equal(t, "spiffe://example.com", proto.TokenGrant.JwtSvidAudience) + assert.Equal(t, []string{"read"}, proto.TokenGrant.Scopes) + assert.Equal(t, int64(300), proto.TokenGrant.CacheTtlSeconds) + assert.Equal(t, "urn:custom", proto.TokenGrant.ClientAssertionType) + require.Len(t, proto.TokenGrant.AudienceOverrides, 1) + assert.Equal(t, "h", proto.TokenGrant.AudienceOverrides[0].Host) +} + +func TestProfileCredentialToProto_Nil(t *testing.T) { + proto := ProfileCredentialToProto(nil) + assert.Nil(t, proto) +} + +func TestProfileCredentialToProto_DeepCopy(t *testing.T) { + cred := &v1.ProfileCredential{ + Name: "KEY", + EnvVars: []string{"ENV_A"}, + TokenGrant: &v1.CredentialTokenGrant{ + Scopes: []string{"read"}, + }, + } + + proto := ProfileCredentialToProto(cred) + + cred.EnvVars[0] = "MUTATED" + assert.Equal(t, "ENV_A", proto.EnvVars[0], "env_vars must be deep copied") + + cred.TokenGrant.Scopes[0] = "MUTATED" + assert.Equal(t, "read", proto.TokenGrant.Scopes[0], "token grant scopes must be deep copied") +} + +// --- ProfileDiagnostic --- + +func TestProfileDiagnosticFromProto(t *testing.T) { + proto := &pb.ProviderProfileDiagnostic{ + Source: "import", + ProfileId: "prof-1", + Field: "credentials", + Message: "missing required field", + Severity: "error", + } + + diag := ProfileDiagnosticFromProto(proto) + + require.NotNil(t, diag) + assert.Equal(t, "import", diag.Source) + assert.Equal(t, "prof-1", diag.ProfileID) + assert.Equal(t, "credentials", diag.Field) + assert.Equal(t, "missing required field", diag.Message) + assert.Equal(t, "error", diag.Severity) +} + +func TestProfileDiagnosticFromProto_Nil(t *testing.T) { + diag := ProfileDiagnosticFromProto(nil) + assert.Nil(t, diag) +} + +// --- ProviderProfile --- + +func TestProviderProfileFromProto(t *testing.T) { + proto := &pb.ProviderProfile{ + Id: "prof-1", + DisplayName: "Claude Provider", + Description: "Anthropic Claude", + Category: pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, + Credentials: []*pb.ProviderProfileCredential{ + {Name: "API_KEY", Description: "key", Required: true}, + }, + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/claude"}, + }, + InferenceCapable: true, + Discovery: &pb.ProviderProfileDiscovery{ + Credentials: []string{"API_KEY"}, + }, + ResourceVersion: 7, + Annotations: map[string]string{"env": "prod", "team": "ai"}, + Source: "builtin", + Scope: "platform", + } + + profile := ProviderProfileFromProto(proto) + + require.NotNil(t, profile) + assert.Equal(t, "prof-1", profile.ID) + assert.Equal(t, "Claude Provider", profile.DisplayName) + assert.Equal(t, "Anthropic Claude", profile.Description) + assert.Equal(t, v1.ProfileCategoryInference, profile.Category) + assert.True(t, profile.InferenceCapable) + assert.Equal(t, uint64(7), profile.ResourceVersion) + assert.Equal(t, map[string]string{"env": "prod", "team": "ai"}, profile.Annotations) + assert.Equal(t, "builtin", profile.Source) + assert.Equal(t, "platform", profile.Scope) + + require.Len(t, profile.Credentials, 1) + assert.Equal(t, "API_KEY", profile.Credentials[0].Name) + assert.True(t, profile.Credentials[0].Required) + + require.Len(t, profile.Endpoints, 1) + assert.Equal(t, "api.anthropic.com", profile.Endpoints[0].Host) + assert.Equal(t, uint32(443), profile.Endpoints[0].Port) + + require.Len(t, profile.Binaries, 1) + assert.Equal(t, "/usr/bin/claude", profile.Binaries[0].Path) + + assert.Equal(t, []string{"API_KEY"}, profile.Discovery.Credentials) + + proto.Annotations["env"] = "MUTATED" + assert.Equal(t, "prod", profile.Annotations["env"], "annotations must be deep copied") +} + +func TestProviderProfileFromProto_NilDiscovery(t *testing.T) { + proto := &pb.ProviderProfile{ + Id: "prof-2", + } + + profile := ProviderProfileFromProto(proto) + + require.NotNil(t, profile) + assert.Nil(t, profile.Discovery.Credentials) +} + +func TestProviderProfileFromProto_Nil(t *testing.T) { + profile := ProviderProfileFromProto(nil) + assert.Nil(t, profile) +} + +func TestProviderProfileToProto(t *testing.T) { + profile := &v1.ProviderProfile{ + ID: "prof-1", + DisplayName: "Claude Provider", + Description: "Anthropic Claude", + Category: v1.ProfileCategoryInference, + Credentials: []v1.ProfileCredential{ + {Name: "API_KEY", Description: "key", Required: true, Secret: true}, + }, + Endpoints: []v1.NetworkEndpoint{ + {Host: "api.anthropic.com", Port: 443, Protocol: "rest"}, + }, + Binaries: []v1.NetworkBinary{ + {Path: "/usr/bin/claude"}, + }, + InferenceCapable: true, + Discovery: v1.ProfileDiscovery{ + Credentials: []string{"API_KEY"}, + }, + ResourceVersion: 7, + Annotations: map[string]string{"env": "prod"}, + Source: "user", + Scope: "workspace", + } + + proto := ProviderProfileToProto(profile) + + require.NotNil(t, proto) + assert.Equal(t, "prof-1", proto.Id) + assert.Equal(t, "Claude Provider", proto.DisplayName) + assert.Equal(t, "Anthropic Claude", proto.Description) + assert.Equal(t, pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE, proto.Category) + assert.True(t, proto.InferenceCapable) + assert.Equal(t, uint64(7), proto.ResourceVersion) + assert.Equal(t, map[string]string{"env": "prod"}, proto.Annotations) + assert.Equal(t, "user", proto.Source) + assert.Equal(t, "workspace", proto.Scope) + + require.Len(t, proto.Credentials, 1) + assert.Equal(t, "API_KEY", proto.Credentials[0].Name) + + require.Len(t, proto.Endpoints, 1) + assert.Equal(t, "api.anthropic.com", proto.Endpoints[0].Host) + + require.Len(t, proto.Binaries, 1) + assert.Equal(t, "/usr/bin/claude", proto.Binaries[0].Path) + + require.NotNil(t, proto.Discovery) + assert.Equal(t, []string{"API_KEY"}, proto.Discovery.Credentials) + + profile.Annotations["env"] = "MUTATED" + assert.Equal(t, "prod", proto.Annotations["env"], "annotations must be deep copied") +} + +func TestProviderProfileToProto_Nil(t *testing.T) { + proto := ProviderProfileToProto(nil) + assert.Nil(t, proto) +} + +// --- ProfileImportItem --- + +func TestProfileImportItemToProto(t *testing.T) { + item := &v1.ProfileImportItem{ + Profile: v1.ProviderProfile{ + ID: "prof-1", + DisplayName: "Test", + Category: v1.ProfileCategoryOther, + }, + Source: "file:///profiles/test.yaml", + } + + proto := ProfileImportItemToProto(item) + + require.NotNil(t, proto) + assert.Equal(t, "file:///profiles/test.yaml", proto.Source) + require.NotNil(t, proto.Profile) + assert.Equal(t, "prof-1", proto.Profile.Id) + assert.Equal(t, "Test", proto.Profile.DisplayName) +} + +func TestProfileImportItemToProto_Nil(t *testing.T) { + proto := ProfileImportItemToProto(nil) + assert.Nil(t, proto) +} + +func TestProfileImportItemFromProto(t *testing.T) { + proto := &pb.ProviderProfileImportItem{ + Profile: &pb.ProviderProfile{ + Id: "prof-1", + DisplayName: "Test", + }, + Source: "file:///profiles/test.yaml", + } + + item := ProfileImportItemFromProto(proto) + + require.NotNil(t, item) + assert.Equal(t, "file:///profiles/test.yaml", item.Source) + assert.Equal(t, "prof-1", item.Profile.ID) +} + +func TestProfileImportItemFromProto_Nil(t *testing.T) { + item := ProfileImportItemFromProto(nil) + assert.Nil(t, item) +} + +// --- ProviderProfile round-trip --- + +func TestProviderProfileRoundTrip(t *testing.T) { + original := &v1.ProviderProfile{ + ID: "prof-rt", + DisplayName: "Round Trip", + Description: "Testing round trip", + Category: v1.ProfileCategoryAgent, + Credentials: []v1.ProfileCredential{ + { + Name: "TOKEN", + Description: "auth token", + EnvVars: []string{"MY_TOKEN"}, + Required: true, + Secret: false, + AuthStyle: "header", + HeaderName: "Authorization", + QueryParam: "token", + PathTemplate: "/api/{credential}", + TokenGrant: &v1.CredentialTokenGrant{ + TokenEndpoint: "https://auth.example.com/token", + Audience: "https://api.example.com", + JWTSVIDAudience: "spiffe://example.com", + Scopes: []string{"read"}, + CacheTTLSeconds: 600, + ClientAssertionType: "urn:custom", + AudienceOverrides: []v1.TokenGrantAudienceOverride{ + {Host: "h", Port: 443, Path: "/p", Audience: "aud", Scopes: []string{"s"}}, + }, + }, + }, + }, + Endpoints: []v1.NetworkEndpoint{ + {Host: "agent.example.com", Port: 8080, Protocol: "websocket"}, + }, + Binaries: []v1.NetworkBinary{ + {Path: "/bin/agent"}, + }, + InferenceCapable: false, + Discovery: v1.ProfileDiscovery{ + Credentials: []string{"TOKEN"}, + }, + ResourceVersion: 42, + Annotations: map[string]string{"env": "staging"}, + Source: "interceptor/custom", + Scope: "workspace", + } + + proto := ProviderProfileToProto(original) + back := ProviderProfileFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.DisplayName, back.DisplayName) + assert.Equal(t, original.Description, back.Description) + assert.Equal(t, original.Category, back.Category) + assert.Equal(t, original.InferenceCapable, back.InferenceCapable) + assert.Equal(t, original.ResourceVersion, back.ResourceVersion) + assert.Equal(t, original.Annotations, back.Annotations) + assert.Equal(t, original.Source, back.Source) + assert.Equal(t, original.Scope, back.Scope) + + require.Len(t, back.Credentials, 1) + c := back.Credentials[0] + assert.Equal(t, original.Credentials[0].Name, c.Name) + assert.Equal(t, original.Credentials[0].Required, c.Required) + assert.Equal(t, original.Credentials[0].EnvVars, c.EnvVars) + assert.Equal(t, original.Credentials[0].AuthStyle, c.AuthStyle) + assert.Equal(t, original.Credentials[0].HeaderName, c.HeaderName) + assert.Equal(t, original.Credentials[0].QueryParam, c.QueryParam) + assert.Equal(t, original.Credentials[0].PathTemplate, c.PathTemplate) + require.NotNil(t, c.TokenGrant) + assert.Equal(t, original.Credentials[0].TokenGrant.TokenEndpoint, c.TokenGrant.TokenEndpoint) + assert.Equal(t, original.Credentials[0].TokenGrant.Audience, c.TokenGrant.Audience) + assert.Equal(t, original.Credentials[0].TokenGrant.JWTSVIDAudience, c.TokenGrant.JWTSVIDAudience) + assert.Equal(t, original.Credentials[0].TokenGrant.Scopes, c.TokenGrant.Scopes) + assert.Equal(t, original.Credentials[0].TokenGrant.CacheTTLSeconds, c.TokenGrant.CacheTTLSeconds) + assert.Equal(t, original.Credentials[0].TokenGrant.ClientAssertionType, c.TokenGrant.ClientAssertionType) + require.Len(t, c.TokenGrant.AudienceOverrides, 1) + assert.Equal(t, original.Credentials[0].TokenGrant.AudienceOverrides[0], c.TokenGrant.AudienceOverrides[0]) + + require.Len(t, back.Endpoints, 1) + assert.Equal(t, original.Endpoints[0].Host, back.Endpoints[0].Host) + assert.Equal(t, original.Endpoints[0].Port, back.Endpoints[0].Port) + + require.Len(t, back.Binaries, 1) + assert.Equal(t, original.Binaries[0].Path, back.Binaries[0].Path) + + assert.Equal(t, original.Discovery.Credentials, back.Discovery.Credentials) +} diff --git a/sdk/go/openshell/v1/internal/converter/provider_test.go b/sdk/go/openshell/v1/internal/converter/provider_test.go index dcead666a0..84411830c4 100644 --- a/sdk/go/openshell/v1/internal/converter/provider_test.go +++ b/sdk/go/openshell/v1/internal/converter/provider_test.go @@ -143,6 +143,56 @@ func TestProviderToProto_Full(t *testing.T) { assert.Equal(t, map[string]string{"k": "v"}, h.Metadata) } +func TestProviderFromProto_DeepCopyCredentialHandles(t *testing.T) { + proto := &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "deep-copy-test"}, + Type: "test", + CredentialHandles: map[string]*dm.CredentialHandle{ + "key": { + Driver: "vault", + Handle: "secret/test", + Metadata: map[string]string{"version": "1"}, + }, + }, + } + + result := ProviderFromProto(proto) + require.Len(t, result.Spec.CredentialHandles, 1) + + proto.CredentialHandles["key"].Metadata["version"] = "mutated" + proto.CredentialHandles["key"].Driver = "mutated" + + assert.Equal(t, "1", result.Spec.CredentialHandles["key"].Metadata["version"]) + assert.Equal(t, "vault", result.Spec.CredentialHandles["key"].Driver) +} + +func TestProviderToProto_DeepCopyCredentialHandles(t *testing.T) { + provider := &types.Provider{ + Name: "deep-copy-test", + Type: "test", + Spec: types.ProviderSpec{ + CredentialHandles: map[string]types.CredentialHandle{ + "token": { + Driver: "k8s", + Handle: "ns/secret", + Metadata: map[string]string{"k": "v"}, + }, + }, + }, + } + + result := ProviderToProto(provider) + require.Len(t, result.CredentialHandles, 1) + + provider.Spec.CredentialHandles["token"] = types.CredentialHandle{ + Driver: "mutated", Handle: "mutated", Metadata: map[string]string{"k": "mutated"}, + } + + assert.Equal(t, "k8s", result.CredentialHandles["token"].Driver) + assert.Equal(t, "ns/secret", result.CredentialHandles["token"].Handle) + assert.Equal(t, "v", result.CredentialHandles["token"].Metadata["k"]) +} + func TestProviderRoundTrip(t *testing.T) { original := &types.Provider{ ID: "rt-1", diff --git a/sdk/go/openshell/v1/internal/converter/refresh.go b/sdk/go/openshell/v1/internal/converter/refresh.go new file mode 100644 index 0000000000..b417718a5b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/refresh.go @@ -0,0 +1,96 @@ +// 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" +) + +// --- RefreshStrategy enum mapping --- + +// RefreshStrategyFromProto converts a proto ProviderCredentialRefreshStrategy to an SDK RefreshStrategy. +func RefreshStrategyFromProto(s pb.ProviderCredentialRefreshStrategy) types.RefreshStrategy { + switch s { + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC: + return types.RefreshStrategyStatic + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL: + return types.RefreshStrategyExternal + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN: + return types.RefreshStrategyOAuth2RefreshToken + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS: + return types.RefreshStrategyOAuth2ClientCredentials + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT: + return types.RefreshStrategyGoogleServiceAccountJWT + case pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE: + return types.RefreshStrategyAWSStsAssumeRole + default: + return types.RefreshStrategy("") + } +} + +// RefreshStrategyToProto converts an SDK RefreshStrategy to a proto ProviderCredentialRefreshStrategy. +func RefreshStrategyToProto(s types.RefreshStrategy) pb.ProviderCredentialRefreshStrategy { + switch s { + case types.RefreshStrategyStatic: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC + case types.RefreshStrategyExternal: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL + case types.RefreshStrategyOAuth2RefreshToken: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN + case types.RefreshStrategyOAuth2ClientCredentials: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS + case types.RefreshStrategyGoogleServiceAccountJWT: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT + case types.RefreshStrategyAWSStsAssumeRole: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE + default: + return pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED + } +} + +// --- RefreshStatus --- + +// RefreshStatusFromProto converts a proto ProviderCredentialRefreshStatus to an SDK RefreshStatus. +func RefreshStatusFromProto(s *pb.ProviderCredentialRefreshStatus) *types.RefreshStatus { + if s == nil { + return nil + } + return &types.RefreshStatus{ + ProviderName: s.GetProviderName(), + ProviderID: s.GetProviderId(), + CredentialKey: s.GetCredentialKey(), + Strategy: RefreshStrategyFromProto(s.GetStrategy()), + Status: s.GetStatus(), + ExpiresAt: TimeFromMillis(s.GetExpiresAtMs()), + NextRefreshAt: TimeFromMillis(s.GetNextRefreshAtMs()), + LastRefreshAt: TimeFromMillis(s.GetLastRefreshAtMs()), + LastError: s.GetLastError(), + } +} + +// --- RefreshConfig --- + +// RefreshConfigToProto converts an SDK RefreshConfig to a proto ConfigureProviderRefreshRequest. +// Material and SecretMaterialKeys are deep-copied. +func RefreshConfigToProto(c *types.RefreshConfig) *pb.ConfigureProviderRefreshRequest { + if c == nil { + return nil + } + + result := &pb.ConfigureProviderRefreshRequest{ + Provider: c.Provider, + CredentialKey: c.CredentialKey, + Strategy: RefreshStrategyToProto(c.Strategy), + Material: CopyStringMap(c.Material), + SecretMaterialKeys: CopyStringSlice(c.SecretMaterialKeys), + } + + if c.ExpiresAt != nil { + ms := MillisFromTime(*c.ExpiresAt) + result.ExpiresAtMs = &ms + } + + return result +} diff --git a/sdk/go/openshell/v1/internal/converter/refresh_test.go b/sdk/go/openshell/v1/internal/converter/refresh_test.go new file mode 100644 index 0000000000..b426857c96 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/refresh_test.go @@ -0,0 +1,168 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- RefreshStrategy --- + +func TestRefreshStrategyFromProto(t *testing.T) { + tests := []struct { + proto pb.ProviderCredentialRefreshStrategy + want v1.RefreshStrategy + }{ + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC, v1.RefreshStrategyStatic}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL, v1.RefreshStrategyExternal}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, v1.RefreshStrategyOAuth2RefreshToken}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS, v1.RefreshStrategyOAuth2ClientCredentials}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT, v1.RefreshStrategyGoogleServiceAccountJWT}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE, v1.RefreshStrategyAWSStsAssumeRole}, + {pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED, v1.RefreshStrategy("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, RefreshStrategyFromProto(tt.proto)) + }) + } +} + +func TestRefreshStrategyToProto(t *testing.T) { + tests := []struct { + sdk v1.RefreshStrategy + want pb.ProviderCredentialRefreshStrategy + }{ + {v1.RefreshStrategyStatic, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC}, + {v1.RefreshStrategyExternal, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_EXTERNAL}, + {v1.RefreshStrategyOAuth2RefreshToken, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN}, + {v1.RefreshStrategyOAuth2ClientCredentials, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS}, + {v1.RefreshStrategyGoogleServiceAccountJWT, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_GOOGLE_SERVICE_ACCOUNT_JWT}, + {v1.RefreshStrategyAWSStsAssumeRole, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_AWS_STS_ASSUME_ROLE}, + {v1.RefreshStrategy(""), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, + {v1.RefreshStrategy("Unknown"), pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, RefreshStrategyToProto(tt.sdk)) + }) + } +} + +// --- RefreshStatus --- + +func TestRefreshStatusFromProto(t *testing.T) { + proto := &pb.ProviderCredentialRefreshStatus{ + ProviderName: "anthropic", + ProviderId: "prov-1", + CredentialKey: "API_KEY", + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_REFRESH_TOKEN, + Status: "active", + ExpiresAtMs: 1700000000000, + NextRefreshAtMs: 1699999000000, + LastRefreshAtMs: 1699998000000, + LastError: "none", + } + + status := RefreshStatusFromProto(proto) + + require.NotNil(t, status) + assert.Equal(t, "anthropic", status.ProviderName) + assert.Equal(t, "prov-1", status.ProviderID) + assert.Equal(t, "API_KEY", status.CredentialKey) + assert.Equal(t, v1.RefreshStrategyOAuth2RefreshToken, status.Strategy) + assert.Equal(t, "active", status.Status) + assert.Equal(t, TimeFromMillis(1700000000000), status.ExpiresAt) + assert.Equal(t, TimeFromMillis(1699999000000), status.NextRefreshAt) + assert.Equal(t, TimeFromMillis(1699998000000), status.LastRefreshAt) + assert.Equal(t, "none", status.LastError) +} + +func TestRefreshStatusFromProto_Nil(t *testing.T) { + status := RefreshStatusFromProto(nil) + assert.Nil(t, status) +} + +func TestRefreshStatusFromProto_ZeroTimestamps(t *testing.T) { + proto := &pb.ProviderCredentialRefreshStatus{ + ProviderName: "test", + CredentialKey: "KEY", + Strategy: pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_STATIC, + } + + status := RefreshStatusFromProto(proto) + + require.NotNil(t, status) + assert.Equal(t, v1.RefreshStrategyStatic, status.Strategy) + assert.True(t, status.ExpiresAt.IsZero()) + assert.True(t, status.NextRefreshAt.IsZero()) + assert.True(t, status.LastRefreshAt.IsZero()) +} + +// --- RefreshConfig --- + +func TestRefreshConfigToProto(t *testing.T) { + expiresAt := time.Unix(1700000000, 0) + config := &v1.RefreshConfig{ + Provider: "anthropic", + CredentialKey: "API_KEY", + Strategy: v1.RefreshStrategyOAuth2ClientCredentials, + Material: map[string]string{ + "client_id": "my-id", + "client_secret": "my-secret", + }, + SecretMaterialKeys: []string{"client_secret"}, + ExpiresAt: &expiresAt, + } + + proto := RefreshConfigToProto(config) + + require.NotNil(t, proto) + assert.Equal(t, "anthropic", proto.Provider) + assert.Equal(t, "API_KEY", proto.CredentialKey) + assert.Equal(t, pb.ProviderCredentialRefreshStrategy_PROVIDER_CREDENTIAL_REFRESH_STRATEGY_OAUTH2_CLIENT_CREDENTIALS, proto.Strategy) + + // Material is deep-copied + require.Len(t, proto.Material, 2) + assert.Equal(t, "my-id", proto.Material["client_id"]) + assert.Equal(t, "my-secret", proto.Material["client_secret"]) + + // Verify deep copy by mutating original + config.Material["client_id"] = "mutated" + assert.Equal(t, "my-id", proto.Material["client_id"], "material must be deep copied") + + assert.Equal(t, []string{"client_secret"}, proto.SecretMaterialKeys) + + // Verify SecretMaterialKeys deep copy + config.SecretMaterialKeys[0] = "mutated" + assert.Equal(t, "client_secret", proto.SecretMaterialKeys[0], "secret keys must be deep copied") + + // ExpiresAt conversion + require.NotNil(t, proto.ExpiresAtMs) + assert.Equal(t, MillisFromTime(expiresAt), *proto.ExpiresAtMs) +} + +func TestRefreshConfigToProto_NilExpiresAt(t *testing.T) { + config := &v1.RefreshConfig{ + Provider: "test", + CredentialKey: "KEY", + Strategy: v1.RefreshStrategyStatic, + } + + proto := RefreshConfigToProto(config) + + require.NotNil(t, proto) + assert.Nil(t, proto.ExpiresAtMs) +} + +func TestRefreshConfigToProto_Nil(t *testing.T) { + proto := RefreshConfigToProto(nil) + assert.Nil(t, proto) +} diff --git a/sdk/go/openshell/v1/internal/converter/sandbox.go b/sdk/go/openshell/v1/internal/converter/sandbox.go index b522454b83..d9f45e8df2 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox.go @@ -9,6 +9,7 @@ import ( "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/protobuf/types/known/structpb" ) // SandboxFromProto converts a proto Sandbox to an SDK Sandbox. @@ -52,17 +53,22 @@ func sandboxSpecFromProto(spec *pb.SandboxSpec) types.SandboxSpec { } if tmpl := spec.GetTemplate(); tmpl != nil { - result.Template = &types.SandboxTemplate{ + t := &types.SandboxTemplate{ Image: tmpl.GetImage(), RuntimeClassName: tmpl.GetRuntimeClassName(), AgentSocket: tmpl.GetAgentSocket(), Labels: CopyStringMap(tmpl.GetLabels()), Annotations: CopyStringMap(tmpl.GetAnnotations()), Environment: CopyStringMap(tmpl.GetEnvironment()), - Resources: structToMap(tmpl.GetResources()), UserNamespaces: CopyBoolPtr(tmpl.UserNamespaces), - DriverConfig: structToMap(tmpl.GetDriverConfig()), } + if res := tmpl.GetResources(); res != nil { + t.Resources = res.AsMap() + } + if dc := tmpl.GetDriverConfig(); dc != nil { + t.DriverConfig = dc.AsMap() + } + result.Template = t } if rr := spec.GetResourceRequirements(); rr != nil { @@ -134,14 +140,9 @@ func SandboxPhaseToProto(phase types.SandboxPhase) pb.SandboxPhase { } // SandboxToProto converts an SDK Sandbox to a proto Sandbox. -func SandboxToProto(s *types.Sandbox) (*pb.Sandbox, error) { +func SandboxToProto(s *types.Sandbox) *pb.Sandbox { if s == nil { - return nil, nil - } - - spec, err := SandboxSpecToProto(&s.Spec) - if err != nil { - return nil, fmt.Errorf("convert sandbox spec: %w", err) + return nil } return &pb.Sandbox{ @@ -155,14 +156,14 @@ func SandboxToProto(s *types.Sandbox) (*pb.Sandbox, error) { Workspace: s.Workspace, DeletionTimestampMs: MillisFromTimePtr(s.DeletionTimestamp), }, - Spec: spec, - }, nil + Spec: SandboxSpecToProto(&s.Spec), + } } // SandboxSpecToProto converts an SDK SandboxSpec to a proto SandboxSpec. -func SandboxSpecToProto(spec *types.SandboxSpec) (*pb.SandboxSpec, error) { +func SandboxSpecToProto(spec *types.SandboxSpec) *pb.SandboxSpec { if spec == nil { - return nil, nil + return nil } result := &pb.SandboxSpec{ @@ -173,25 +174,30 @@ func SandboxSpecToProto(spec *types.SandboxSpec) (*pb.SandboxSpec, error) { } if spec.Template != nil { - resources, err := mapToStruct(spec.Template.Resources) - if err != nil { - return nil, fmt.Errorf("convert template resources: %w", err) - } - driverConfig, err := mapToStruct(spec.Template.DriverConfig) - if err != nil { - return nil, fmt.Errorf("convert template driver config: %w", err) - } - result.Template = &pb.SandboxTemplate{ + tmpl := &pb.SandboxTemplate{ Image: spec.Template.Image, RuntimeClassName: spec.Template.RuntimeClassName, AgentSocket: spec.Template.AgentSocket, Labels: CopyStringMap(spec.Template.Labels), Annotations: CopyStringMap(spec.Template.Annotations), Environment: CopyStringMap(spec.Template.Environment), - Resources: resources, UserNamespaces: CopyBoolPtr(spec.Template.UserNamespaces), - DriverConfig: driverConfig, } + if spec.Template.Resources != nil { + // Non-JSON-compatible values (e.g., chan, func) are silently dropped. + // Round-trip data from structpb.AsMap is always re-serializable. + s, err := structpb.NewStruct(spec.Template.Resources) + if err == nil { + tmpl.Resources = s + } + } + if spec.Template.DriverConfig != nil { + s, err := structpb.NewStruct(spec.Template.DriverConfig) + if err == nil { + tmpl.DriverConfig = s + } + } + result.Template = tmpl } if spec.GPUCount != nil { @@ -202,5 +208,37 @@ func SandboxSpecToProto(spec *types.SandboxSpec) (*pb.SandboxSpec, error) { } } + return result +} + +// SandboxSpecToProtoChecked converts an SDK SandboxSpec and reports values +// that protobuf Struct cannot represent instead of silently dropping them. +func SandboxSpecToProtoChecked(spec *types.SandboxSpec) (*pb.SandboxSpec, error) { + result := SandboxSpecToProto(spec) + if spec == nil { + return result, nil + } + policy, err := SandboxPolicyToProtoChecked(spec.Policy) + if err != nil { + return nil, fmt.Errorf("policy: %w", err) + } + result.Policy = policy + if spec.Template == nil { + return result, nil + } + if spec.Template.Resources != nil { + resources, err := structpb.NewStruct(spec.Template.Resources) + if err != nil { + return nil, fmt.Errorf("template resources: %w", err) + } + result.Template.Resources = resources + } + if spec.Template.DriverConfig != nil { + driverConfig, err := structpb.NewStruct(spec.Template.DriverConfig) + if err != nil { + return nil, fmt.Errorf("template driver config: %w", err) + } + result.Template.DriverConfig = driverConfig + } return result, nil } diff --git a/sdk/go/openshell/v1/internal/converter/sandbox_test.go b/sdk/go/openshell/v1/internal/converter/sandbox_test.go index 8cbc4d3b81..01c73e4a4c 100644 --- a/sdk/go/openshell/v1/internal/converter/sandbox_test.go +++ b/sdk/go/openshell/v1/internal/converter/sandbox_test.go @@ -13,6 +13,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "google.golang.org/protobuf/proto" + "google.golang.org/protobuf/types/known/structpb" ) func TestSandboxFromProto(t *testing.T) { @@ -40,6 +41,14 @@ func TestSandboxFromProto(t *testing.T) { Annotations: map[string]string{"note": "hello"}, Environment: map[string]string{"TMPL_VAR": "val"}, UserNamespaces: &userNS, + Resources: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{"cpu": "2", "memory": "4Gi"}) + return s + }(), + DriverConfig: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{"runtime": "kata", "nested": map[string]any{"key": "val"}}) + return s + }(), }, Providers: []string{"claude", "github"}, ResourceRequirements: &pb.ResourceRequirements{ @@ -97,6 +106,11 @@ func TestSandboxFromProto(t *testing.T) { assert.Equal(t, map[string]string{"TMPL_VAR": "val"}, s.Spec.Template.Environment) require.NotNil(t, s.Spec.Template.UserNamespaces) assert.True(t, *s.Spec.Template.UserNamespaces) + assert.Equal(t, map[string]any{"cpu": "2", "memory": "4Gi"}, s.Spec.Template.Resources) + assert.Equal(t, "kata", s.Spec.Template.DriverConfig["runtime"]) + nested, ok := s.Spec.Template.DriverConfig["nested"].(map[string]any) + require.True(t, ok) + assert.Equal(t, "val", nested["key"]) // Status assert.Equal(t, "sb-compute-1", s.Status.SandboxName) @@ -113,6 +127,33 @@ func TestSandboxFromProto(t *testing.T) { assert.Equal(t, "2024-01-01T00:00:00Z", s.Status.Conditions[0].LastTransitionTime) } +func TestSandboxFromProto_TemplateResourcesDeepCopy(t *testing.T) { + proto := &pb.Sandbox{ + Spec: &pb.SandboxSpec{ + Template: &pb.SandboxTemplate{ + Image: "img:v1", + Resources: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{"cpu": "2"}) + return s + }(), + DriverConfig: func() *structpb.Struct { + s, _ := structpb.NewStruct(map[string]any{"runtime": "kata"}) + return s + }(), + }, + }, + } + + s := SandboxFromProto(proto) + require.NotNil(t, s) + + proto.Spec.Template.Resources.Fields["cpu"] = structpb.NewStringValue("MUTATED") + assert.Equal(t, "2", s.Spec.Template.Resources["cpu"], "Resources must be deep copied") + + proto.Spec.Template.DriverConfig.Fields["runtime"] = structpb.NewStringValue("MUTATED") + assert.Equal(t, "kata", s.Spec.Template.DriverConfig["runtime"], "DriverConfig must be deep copied") +} + func TestSandboxFromProto_NilFields(t *testing.T) { proto := &pb.Sandbox{} @@ -199,8 +240,8 @@ func TestSandboxToProto(t *testing.T) { }, } - p, err := SandboxToProto(s) - require.NoError(t, err) + p := SandboxToProto(s) + require.NotNil(t, p) require.NotNil(t, p.Metadata) assert.Equal(t, "sb-1", p.Metadata.Id) @@ -233,8 +274,7 @@ func TestSandboxToProto(t *testing.T) { } func TestSandboxToProto_Nil(t *testing.T) { - p, err := SandboxToProto(nil) - require.NoError(t, err) + p := SandboxToProto(nil) assert.Nil(t, p) } @@ -245,8 +285,8 @@ func TestSandboxToProto_NilTemplate(t *testing.T) { }, } - p, err := SandboxToProto(s) - require.NoError(t, err) + p := SandboxToProto(s) + require.NotNil(t, p) require.NotNil(t, p.Spec) assert.Nil(t, p.Spec.Template) @@ -308,8 +348,7 @@ func TestSandboxRoundTrip(t *testing.T) { }, } - p, err := SandboxToProto(original) - require.NoError(t, err) + p := SandboxToProto(original) back := SandboxFromProto(p) assert.Equal(t, original.ID, back.ID) @@ -359,7 +398,9 @@ func TestSandboxSpecToProto(t *testing.T) { LogLevel: "debug", Environment: map[string]string{"X": "Y"}, Template: &v1.SandboxTemplate{ - Image: "img:spec", + Image: "img:spec", + Resources: map[string]any{"cpu": "4"}, + DriverConfig: map[string]any{"runtime": "kata"}, }, Providers: []string{"prov"}, GPUCount: &gpuCount, @@ -371,8 +412,8 @@ func TestSandboxSpecToProto(t *testing.T) { }, } - p, err := SandboxSpecToProto(spec) - require.NoError(t, err) + p := SandboxSpecToProto(spec) + require.NotNil(t, p) assert.Equal(t, "debug", p.LogLevel) assert.Equal(t, map[string]string{"X": "Y"}, p.Environment) @@ -381,6 +422,10 @@ func TestSandboxSpecToProto(t *testing.T) { assert.Equal(t, uint32(3), p.ResourceRequirements.Gpu.GetCount()) require.NotNil(t, p.Template) assert.Equal(t, "img:spec", p.Template.Image) + require.NotNil(t, p.Template.Resources) + assert.Equal(t, "4", p.Template.Resources.Fields["cpu"].GetStringValue()) + require.NotNil(t, p.Template.DriverConfig) + assert.Equal(t, "kata", p.Template.DriverConfig.Fields["runtime"].GetStringValue()) // Policy conversion require.NotNil(t, p.Policy) @@ -390,23 +435,8 @@ func TestSandboxSpecToProto(t *testing.T) { } func TestSandboxSpecToProto_Nil(t *testing.T) { - p, err := SandboxSpecToProto(nil) - require.NoError(t, err) - assert.Nil(t, p) -} - -func TestSandboxSpecToProto_InvalidMapReturnsError(t *testing.T) { - spec := &v1.SandboxSpec{ - Template: &v1.SandboxTemplate{ - Image: "img:v1", - Resources: map[string]any{"bad": make(chan int)}, - }, - } - - p, err := SandboxSpecToProto(spec) - require.Error(t, err, "SandboxSpecToProto must return an error for unconvertible map values") + p := SandboxSpecToProto(nil) assert.Nil(t, p) - assert.Contains(t, err.Error(), "convert template resources") } // Verify proto import is used (suppress unused import warning). diff --git a/sdk/go/openshell/v1/internal/converter/service.go b/sdk/go/openshell/v1/internal/converter/service.go new file mode 100644 index 0000000000..b36c7e3b9d --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/service.go @@ -0,0 +1,59 @@ +// 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" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// ServiceEndpointFromProto converts a proto ServiceEndpointResponse to an SDK ServiceEndpoint. +// The response flattens the nested Endpoint and top-level URL into a single SDK type. +func ServiceEndpointFromProto(resp *pb.ServiceEndpointResponse) *types.ServiceEndpoint { + if resp == nil { + return nil + } + + result := &types.ServiceEndpoint{ + URL: resp.GetUrl(), + } + + if ep := resp.GetEndpoint(); ep != nil { + result.SandboxID = ep.GetSandboxId() + result.SandboxName = ep.GetSandboxName() + result.ServiceName = ep.GetServiceName() + result.TargetPort = ep.GetTargetPort() + result.Domain = ep.GetDomain() + + if m := ep.GetMetadata(); m != nil { + result.ID = m.GetId() + result.Workspace = m.GetWorkspace() + } + } + + return result +} + +// ServiceEndpointToProto converts an SDK ServiceEndpoint to a proto ServiceEndpointResponse. +func ServiceEndpointToProto(se *types.ServiceEndpoint) *pb.ServiceEndpointResponse { + if se == nil { + return nil + } + + return &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: se.ID, + Workspace: se.Workspace, + }, + SandboxId: se.SandboxID, + SandboxName: se.SandboxName, + ServiceName: se.ServiceName, + TargetPort: se.TargetPort, + Domain: se.Domain, + }, + Url: se.URL, + } +} diff --git a/sdk/go/openshell/v1/internal/converter/service_test.go b/sdk/go/openshell/v1/internal/converter/service_test.go new file mode 100644 index 0000000000..c04ddcd552 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/service_test.go @@ -0,0 +1,131 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServiceEndpointFromProto(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: "svc-1", + }, + SandboxId: "sb-1", + SandboxName: "my-sandbox", + ServiceName: "http-server", + TargetPort: 8080, + Domain: true, + }, + Url: "https://svc-1.example.com", + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Equal(t, "svc-1", se.ID) + assert.Equal(t, "sb-1", se.SandboxID) + assert.Equal(t, "my-sandbox", se.SandboxName) + assert.Equal(t, "http-server", se.ServiceName) + assert.Equal(t, uint32(8080), se.TargetPort) + assert.True(t, se.Domain) + assert.Equal(t, "https://svc-1.example.com", se.URL) +} + +func TestServiceEndpointFromProto_NilEndpoint(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Url: "https://orphan.example.com", + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Empty(t, se.ID) + assert.Empty(t, se.SandboxID) + assert.Equal(t, "https://orphan.example.com", se.URL) +} + +func TestServiceEndpointFromProto_NilMetadata(t *testing.T) { + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + SandboxId: "sb-2", + ServiceName: "api", + TargetPort: 3000, + }, + } + + se := ServiceEndpointFromProto(resp) + + require.NotNil(t, se) + assert.Empty(t, se.ID) + assert.Equal(t, "sb-2", se.SandboxID) + assert.Equal(t, "api", se.ServiceName) + assert.Equal(t, uint32(3000), se.TargetPort) +} + +func TestServiceEndpointFromProto_Nil(t *testing.T) { + se := ServiceEndpointFromProto(nil) + assert.Nil(t, se) +} + +func TestServiceEndpointToProto(t *testing.T) { + se := &v1.ServiceEndpoint{ + ID: "svc-1", + SandboxID: "sb-1", + SandboxName: "my-sandbox", + ServiceName: "http-server", + TargetPort: 8080, + Domain: true, + URL: "https://svc-1.example.com", + } + + resp := ServiceEndpointToProto(se) + + require.NotNil(t, resp) + require.NotNil(t, resp.Endpoint) + require.NotNil(t, resp.Endpoint.Metadata) + assert.Equal(t, "svc-1", resp.Endpoint.Metadata.Id) + assert.Equal(t, "sb-1", resp.Endpoint.SandboxId) + assert.Equal(t, "my-sandbox", resp.Endpoint.SandboxName) + assert.Equal(t, "http-server", resp.Endpoint.ServiceName) + assert.Equal(t, uint32(8080), resp.Endpoint.TargetPort) + assert.True(t, resp.Endpoint.Domain) + assert.Equal(t, "https://svc-1.example.com", resp.Url) +} + +func TestServiceEndpointToProto_Nil(t *testing.T) { + resp := ServiceEndpointToProto(nil) + assert.Nil(t, resp) +} + +func TestServiceEndpointRoundTrip(t *testing.T) { + original := &v1.ServiceEndpoint{ + ID: "svc-rt", + SandboxID: "sb-rt", + SandboxName: "round-trip", + ServiceName: "web", + TargetPort: 9090, + Domain: false, + URL: "http://localhost:9090", + } + + proto := ServiceEndpointToProto(original) + back := ServiceEndpointFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.ID, back.ID) + assert.Equal(t, original.SandboxID, back.SandboxID) + assert.Equal(t, original.SandboxName, back.SandboxName) + assert.Equal(t, original.ServiceName, back.ServiceName) + assert.Equal(t, original.TargetPort, back.TargetPort) + assert.Equal(t, original.Domain, back.Domain) + assert.Equal(t, original.URL, back.URL) +} diff --git a/sdk/go/openshell/v1/internal/converter/setting.go b/sdk/go/openshell/v1/internal/converter/setting.go new file mode 100644 index 0000000000..495545938d --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/setting.go @@ -0,0 +1,306 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "fmt" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" +) + +// --- SettingValue oneof conversion --- + +// SettingValueFromProto converts a proto SettingValue (oneof) to an SDK SettingValue. +func SettingValueFromProto(pv *sbv1.SettingValue) *v1.SettingValue { + if pv == nil { + return nil + } + sv := &v1.SettingValue{} + switch v := pv.GetValue().(type) { + case *sbv1.SettingValue_StringValue: + sv.Type = v1.SettingValueString + sv.StringVal = v.StringValue + case *sbv1.SettingValue_BoolValue: + sv.Type = v1.SettingValueBool + sv.BoolVal = v.BoolValue + case *sbv1.SettingValue_IntValue: + sv.Type = v1.SettingValueInt + sv.IntVal = v.IntValue + case *sbv1.SettingValue_BytesValue: + sv.Type = v1.SettingValueBytes + sv.BytesVal = CopyByteSlice(v.BytesValue) + } + return sv +} + +// SettingValueToProto converts an SDK SettingValue to a proto SettingValue (oneof). +func SettingValueToProto(sv *v1.SettingValue) *sbv1.SettingValue { + if sv == nil { + return nil + } + pv := &sbv1.SettingValue{} + switch sv.Type { + case v1.SettingValueString: + pv.Value = &sbv1.SettingValue_StringValue{StringValue: sv.StringVal} + case v1.SettingValueBool: + pv.Value = &sbv1.SettingValue_BoolValue{BoolValue: sv.BoolVal} + case v1.SettingValueInt: + pv.Value = &sbv1.SettingValue_IntValue{IntValue: sv.IntVal} + case v1.SettingValueBytes: + pv.Value = &sbv1.SettingValue_BytesValue{BytesValue: CopyByteSlice(sv.BytesVal)} + } + return pv +} + +// --- Enum conversions --- + +// SettingScopeFromProto converts a proto SettingScope enum to an SDK SettingScope. +func SettingScopeFromProto(ps sbv1.SettingScope) v1.SettingScope { + switch ps { + case sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED: + return v1.SettingScopeUnspecified + case sbv1.SettingScope_SETTING_SCOPE_SANDBOX: + return v1.SettingScopeSandbox + case sbv1.SettingScope_SETTING_SCOPE_GLOBAL: + return v1.SettingScopeGlobal + default: + return v1.SettingScope("") + } +} + +// SettingScopeToProto converts an SDK SettingScope to a proto SettingScope enum. +func SettingScopeToProto(s v1.SettingScope) sbv1.SettingScope { + switch s { + case v1.SettingScopeUnspecified: + return sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED + case v1.SettingScopeSandbox: + return sbv1.SettingScope_SETTING_SCOPE_SANDBOX + case v1.SettingScopeGlobal: + return sbv1.SettingScope_SETTING_SCOPE_GLOBAL + default: + return sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED + } +} + +// PolicySourceFromProto converts a proto PolicySource enum to an SDK PolicySource. +func PolicySourceFromProto(ps sbv1.PolicySource) v1.PolicySource { + switch ps { + case sbv1.PolicySource_POLICY_SOURCE_UNSPECIFIED: + return v1.PolicySourceUnspecified + case sbv1.PolicySource_POLICY_SOURCE_SANDBOX: + return v1.PolicySourceSandbox + case sbv1.PolicySource_POLICY_SOURCE_GLOBAL: + return v1.PolicySourceGlobal + default: + return v1.PolicySource("") + } +} + +// --- EffectiveSetting --- + +// EffectiveSettingFromProto converts a proto EffectiveSetting to an SDK EffectiveSetting. +func EffectiveSettingFromProto(pv *sbv1.EffectiveSetting) *v1.EffectiveSetting { + if pv == nil { + return nil + } + es := &v1.EffectiveSetting{ + Scope: SettingScopeFromProto(pv.GetScope()), + } + if sv := SettingValueFromProto(pv.GetValue()); sv != nil { + es.Value = *sv + } + return es +} + +// --- SandboxConfig --- + +// SandboxConfigFromProto converts a GetSandboxConfigResponse to an SDK SandboxConfig. +func SandboxConfigFromProto(resp *sbv1.GetSandboxConfigResponse) *v1.SandboxConfig { + if resp == nil { + return nil + } + sc := &v1.SandboxConfig{ + PolicyVersion: resp.GetVersion(), + PolicyHash: resp.GetPolicyHash(), + ConfigRevision: resp.GetConfigRevision(), + PolicySource: PolicySourceFromProto(resp.GetPolicySource()), + GlobalPolicyVersion: resp.GetGlobalPolicyVersion(), + ProviderEnvRevision: resp.GetProviderEnvRevision(), + PolicyValidationFailureMode: resp.GetPolicyValidationFailureMode(), + } + + // Convert proto SandboxPolicy to typed SDK SandboxPolicy. + sc.Policy = SandboxPolicyFromProto(resp.GetPolicy()) + + // Deep-copy settings map. + if m := resp.GetSettings(); len(m) > 0 { + sc.Settings = make(map[string]v1.EffectiveSetting, len(m)) + for k, v := range m { + if es := EffectiveSettingFromProto(v); es != nil { + sc.Settings[k] = *es + } + } + } + + return sc +} + +// --- GatewayConfig --- + +// GatewayConfigFromProto converts a GetGatewayConfigResponse to an SDK GatewayConfig. +func GatewayConfigFromProto(resp *sbv1.GetGatewayConfigResponse) *v1.GatewayConfig { + if resp == nil { + return nil + } + gc := &v1.GatewayConfig{ + SettingsRevision: resp.GetSettingsRevision(), + } + + // Deep-copy settings map. + if m := resp.GetSettings(); len(m) > 0 { + gc.Settings = make(map[string]v1.SettingValue, len(m)) + for k, v := range m { + if sv := SettingValueFromProto(v); sv != nil { + gc.Settings[k] = *sv + } + } + } + + return gc +} + +// --- ConfigUpdate --- + +// ConfigUpdateToProto converts an SDK ConfigUpdate to an UpdateConfigRequest. +func ConfigUpdateToProto(cu *v1.ConfigUpdate) (*pb.UpdateConfigRequest, error) { + if cu == nil { + return nil, nil + } + req := &pb.UpdateConfigRequest{ + Name: cu.Name, + SettingKey: cu.SettingKey, + SettingValue: SettingValueToProto(cu.SettingValue), + DeleteSetting: cu.DeleteSetting, + Global: cu.Global, + ExpectedResourceVersion: cu.ExpectedResourceVersion, + Annotations: CopyStringMap(cu.Annotations), + } + + // Convert typed SDK SandboxPolicy to proto SandboxPolicy. + policy, err := SandboxPolicyToProtoChecked(cu.Policy) + if err != nil { + return nil, err + } + req.Policy = policy + + // Convert typed merge operations with validation. + if len(cu.MergeOperations) > 0 { + req.MergeOperations = make([]*pb.PolicyMergeOperation, len(cu.MergeOperations)) + for i := range cu.MergeOperations { + converted, err := PolicyMergeOperationToProto(&cu.MergeOperations[i]) + if err != nil { + return nil, fmt.Errorf("merge operation [%d]: %w", i, err) + } + req.MergeOperations[i] = converted + } + } + + return req, nil +} + +// --- PolicyMergeOperation --- + +// PolicyMergeOperationToProto converts an SDK PolicyMergeOperation to a proto PolicyMergeOperation. +// Exactly one of the pointer fields must be non-nil. Returns an error if zero or multiple are set. +func PolicyMergeOperationToProto(op *v1.PolicyMergeOperation) (*pb.PolicyMergeOperation, error) { + if op == nil { + return nil, nil + } + set := boolCount(op.AddRule != nil, op.RemoveEndpoint != nil, op.RemoveRule != nil, + op.AddDenyRules != nil, op.AddAllowRules != nil, op.RemoveBinary != nil) + if set != 1 { + return nil, fmt.Errorf("PolicyMergeOperation: exactly one variant must be set, got %d", set) + } + pmo := &pb.PolicyMergeOperation{} + switch { + case op.AddRule != nil: + rule := NetworkPolicyRuleToProto(&op.AddRule.Rule) + pmo.Operation = &pb.PolicyMergeOperation_AddRule{ + AddRule: &pb.AddNetworkRule{ + RuleName: op.AddRule.RuleName, + Rule: rule, + }, + } + case op.RemoveEndpoint != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveEndpoint{ + RemoveEndpoint: &pb.RemoveNetworkEndpoint{ + RuleName: op.RemoveEndpoint.RuleName, + Host: op.RemoveEndpoint.Host, + Port: op.RemoveEndpoint.Port, + }, + } + case op.RemoveRule != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveRule{ + RemoveRule: &pb.RemoveNetworkRule{ + RuleName: op.RemoveRule.RuleName, + }, + } + case op.AddDenyRules != nil: + var denyRules []*sbv1.L7DenyRule + if len(op.AddDenyRules.DenyRules) > 0 { + denyRules = make([]*sbv1.L7DenyRule, len(op.AddDenyRules.DenyRules)) + for i := range op.AddDenyRules.DenyRules { + denyRules[i] = l7DenyRuleToProto(&op.AddDenyRules.DenyRules[i]) + } + } + pmo.Operation = &pb.PolicyMergeOperation_AddDenyRules{ + AddDenyRules: &pb.AddDenyRules{ + Host: op.AddDenyRules.Host, + Port: op.AddDenyRules.Port, + DenyRules: denyRules, + }, + } + case op.AddAllowRules != nil: + var rules []*sbv1.L7Rule + if len(op.AddAllowRules.Rules) > 0 { + rules = make([]*sbv1.L7Rule, len(op.AddAllowRules.Rules)) + for i := range op.AddAllowRules.Rules { + rules[i] = l7RuleToProto(&op.AddAllowRules.Rules[i]) + } + } + pmo.Operation = &pb.PolicyMergeOperation_AddAllowRules{ + AddAllowRules: &pb.AddAllowRules{ + Host: op.AddAllowRules.Host, + Port: op.AddAllowRules.Port, + Rules: rules, + }, + } + case op.RemoveBinary != nil: + pmo.Operation = &pb.PolicyMergeOperation_RemoveBinary{ + RemoveBinary: &pb.RemoveNetworkBinary{ + RuleName: op.RemoveBinary.RuleName, + BinaryPath: op.RemoveBinary.BinaryPath, + }, + } + } + return pmo, nil +} + +// --- ConfigUpdateResult --- + +// ConfigUpdateResultFromProto converts an UpdateConfigResponse to an SDK ConfigUpdateResult. +func ConfigUpdateResultFromProto(resp *pb.UpdateConfigResponse) *v1.ConfigUpdateResult { + if resp == nil { + return nil + } + return &v1.ConfigUpdateResult{ + Version: resp.GetVersion(), + PolicyHash: resp.GetPolicyHash(), + SettingsRevision: resp.GetSettingsRevision(), + Deleted: resp.GetDeleted(), + Annotations: CopyStringMap(resp.GetAnnotations()), + } +} diff --git a/sdk/go/openshell/v1/internal/converter/setting_test.go b/sdk/go/openshell/v1/internal/converter/setting_test.go new file mode 100644 index 0000000000..f546902429 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/setting_test.go @@ -0,0 +1,840 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- SettingValue oneof mapping --- + +func TestSettingValueFromProto_StringValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "hello"}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueString, sv.Type) + assert.Equal(t, "hello", sv.StringVal) + assert.False(t, sv.BoolVal) + assert.Zero(t, sv.IntVal) + assert.Nil(t, sv.BytesVal) +} + +func TestSettingValueFromProto_BoolValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueBool, sv.Type) + assert.True(t, sv.BoolVal) + assert.Empty(t, sv.StringVal) +} + +func TestSettingValueFromProto_IntValue(t *testing.T) { + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 42}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueInt, sv.Type) + assert.Equal(t, int64(42), sv.IntVal) +} + +func TestSettingValueFromProto_BytesValue(t *testing.T) { + data := []byte{0xDE, 0xAD, 0xBE, 0xEF} + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: data}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueBytes, sv.Type) + assert.Equal(t, data, sv.BytesVal) +} + +func TestSettingValueFromProto_BytesDeepCopy(t *testing.T) { + data := []byte{0x01, 0x02, 0x03} + pv := &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BytesValue{BytesValue: data}, + } + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + // Mutate original data — SDK copy must not be affected. + data[0] = 0xFF + assert.Equal(t, byte(0x01), sv.BytesVal[0], "deep copy must isolate SDK from proto") +} + +func TestSettingValueFromProto_NilOneof(t *testing.T) { + pv := &sbv1.SettingValue{} + + sv := SettingValueFromProto(pv) + + require.NotNil(t, sv) + assert.Equal(t, v1.SettingValueType(""), sv.Type) +} + +func TestSettingValueFromProto_Nil(t *testing.T) { + sv := SettingValueFromProto(nil) + assert.Nil(t, sv) +} + +func TestSettingValueToProto_StringValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueString, + StringVal: "world", + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, "world", pv.GetStringValue()) +} + +func TestSettingValueToProto_BoolValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueBool, + BoolVal: true, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.True(t, pv.GetBoolValue()) +} + +func TestSettingValueToProto_IntValue(t *testing.T) { + sv := &v1.SettingValue{ + Type: v1.SettingValueInt, + IntVal: 99, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, int64(99), pv.GetIntValue()) +} + +func TestSettingValueToProto_BytesValue(t *testing.T) { + data := []byte{0xCA, 0xFE} + sv := &v1.SettingValue{ + Type: v1.SettingValueBytes, + BytesVal: data, + } + + pv := SettingValueToProto(sv) + + require.NotNil(t, pv) + assert.Equal(t, data, pv.GetBytesValue()) + + data[0] = 0xFF + assert.Equal(t, byte(0xCA), pv.GetBytesValue()[0], "deep copy must isolate proto from SDK") +} + +func TestSettingValueToProto_Nil(t *testing.T) { + pv := SettingValueToProto(nil) + assert.Nil(t, pv) +} + +// --- SettingScope enum mapping --- + +func TestSettingScopeFromProto(t *testing.T) { + tests := []struct { + proto sbv1.SettingScope + want v1.SettingScope + }{ + {sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED, v1.SettingScopeUnspecified}, + {sbv1.SettingScope_SETTING_SCOPE_SANDBOX, v1.SettingScopeSandbox}, + {sbv1.SettingScope_SETTING_SCOPE_GLOBAL, v1.SettingScopeGlobal}, + {sbv1.SettingScope(999), v1.SettingScope("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, SettingScopeFromProto(tt.proto)) + }) + } +} + +func TestSettingScopeToProto(t *testing.T) { + tests := []struct { + sdk v1.SettingScope + want sbv1.SettingScope + }{ + {v1.SettingScopeUnspecified, sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED}, + {v1.SettingScopeSandbox, sbv1.SettingScope_SETTING_SCOPE_SANDBOX}, + {v1.SettingScopeGlobal, sbv1.SettingScope_SETTING_SCOPE_GLOBAL}, + {v1.SettingScope("unknown"), sbv1.SettingScope_SETTING_SCOPE_UNSPECIFIED}, + } + for _, tt := range tests { + t.Run(string(tt.sdk), func(t *testing.T) { + assert.Equal(t, tt.want, SettingScopeToProto(tt.sdk)) + }) + } +} + +// --- PolicySource enum mapping --- + +func TestPolicySourceFromProto(t *testing.T) { + tests := []struct { + proto sbv1.PolicySource + want v1.PolicySource + }{ + {sbv1.PolicySource_POLICY_SOURCE_UNSPECIFIED, v1.PolicySourceUnspecified}, + {sbv1.PolicySource_POLICY_SOURCE_SANDBOX, v1.PolicySourceSandbox}, + {sbv1.PolicySource_POLICY_SOURCE_GLOBAL, v1.PolicySourceGlobal}, + {sbv1.PolicySource(999), v1.PolicySource("")}, + } + for _, tt := range tests { + t.Run(tt.proto.String(), func(t *testing.T) { + assert.Equal(t, tt.want, PolicySourceFromProto(tt.proto)) + }) + } +} + +// --- EffectiveSetting --- + +func TestEffectiveSettingFromProto(t *testing.T) { + pv := &sbv1.EffectiveSetting{ + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "val"}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + } + + es := EffectiveSettingFromProto(pv) + + require.NotNil(t, es) + assert.Equal(t, v1.SettingValueString, es.Value.Type) + assert.Equal(t, "val", es.Value.StringVal) + assert.Equal(t, v1.SettingScopeSandbox, es.Scope) +} + +func TestEffectiveSettingFromProto_NilValue(t *testing.T) { + pv := &sbv1.EffectiveSetting{ + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + } + + es := EffectiveSettingFromProto(pv) + + require.NotNil(t, es) + assert.Equal(t, v1.SettingValueType(""), es.Value.Type) + assert.Equal(t, v1.SettingScopeGlobal, es.Scope) +} + +func TestEffectiveSettingFromProto_Nil(t *testing.T) { + es := EffectiveSettingFromProto(nil) + assert.Nil(t, es) +} + +// --- SandboxConfig (GetSandboxConfigResponse → SandboxConfig) --- + +func TestSandboxConfigFromProto(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Policy: &sbv1.SandboxPolicy{ + Version: 7, + Filesystem: &sbv1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + Version: 3, + PolicyHash: "sha256:abc", + Settings: map[string]*sbv1.EffectiveSetting{ + "timeout": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_IntValue{IntValue: 30}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + "debug": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_BoolValue{BoolValue: true}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_GLOBAL, + }, + }, + ConfigRevision: 100, + PolicySource: sbv1.PolicySource_POLICY_SOURCE_SANDBOX, + GlobalPolicyVersion: 5, + ProviderEnvRevision: 200, + PolicyValidationFailureMode: "fail_closed", + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + require.NotNil(t, sc.Policy, "typed SandboxPolicy must be populated") + assert.Equal(t, uint32(7), sc.Policy.Version) + require.NotNil(t, sc.Policy.Filesystem) + assert.Equal(t, []string{"/etc"}, sc.Policy.Filesystem.ReadOnly) + assert.Equal(t, uint32(3), sc.PolicyVersion) + assert.Equal(t, "sha256:abc", sc.PolicyHash) + assert.Equal(t, uint64(100), sc.ConfigRevision) + assert.Equal(t, v1.PolicySourceSandbox, sc.PolicySource) + assert.Equal(t, uint32(5), sc.GlobalPolicyVersion) + assert.Equal(t, uint64(200), sc.ProviderEnvRevision) + assert.Equal(t, "fail_closed", sc.PolicyValidationFailureMode) + + require.Len(t, sc.Settings, 2) + + timeout := sc.Settings["timeout"] + assert.Equal(t, v1.SettingValueInt, timeout.Value.Type) + assert.Equal(t, int64(30), timeout.Value.IntVal) + assert.Equal(t, v1.SettingScopeSandbox, timeout.Scope) + + debug := sc.Settings["debug"] + assert.Equal(t, v1.SettingValueBool, debug.Value.Type) + assert.True(t, debug.Value.BoolVal) + assert.Equal(t, v1.SettingScopeGlobal, debug.Scope) +} + +func TestSandboxConfigFromProto_NilPolicy(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Version: 1, + PolicyHash: "sha256:empty", + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + assert.Nil(t, sc.Policy) + assert.Equal(t, uint32(1), sc.PolicyVersion) + assert.Empty(t, sc.Settings) +} + +func TestSandboxConfigFromProto_Nil(t *testing.T) { + sc := SandboxConfigFromProto(nil) + assert.Nil(t, sc) +} + +func TestSandboxConfigFromProto_SettingsDeepCopy(t *testing.T) { + resp := &sbv1.GetSandboxConfigResponse{ + Settings: map[string]*sbv1.EffectiveSetting{ + "key1": { + Value: &sbv1.SettingValue{ + Value: &sbv1.SettingValue_StringValue{StringValue: "original"}, + }, + Scope: sbv1.SettingScope_SETTING_SCOPE_SANDBOX, + }, + }, + } + + sc := SandboxConfigFromProto(resp) + + require.NotNil(t, sc) + // Mutate the proto map — SDK map must not be affected. + resp.Settings["key1"].Value.Value = &sbv1.SettingValue_StringValue{StringValue: "mutated"} + assert.Equal(t, "original", sc.Settings["key1"].Value.StringVal, + "deep copy must isolate SDK settings from proto") +} + +// --- GatewayConfig (GetGatewayConfigResponse → GatewayConfig) --- + +func TestGatewayConfigFromProto(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + Settings: map[string]*sbv1.SettingValue{ + "region": { + Value: &sbv1.SettingValue_StringValue{StringValue: "us-west-2"}, + }, + "max_sandboxes": { + Value: &sbv1.SettingValue_IntValue{IntValue: 100}, + }, + }, + SettingsRevision: 42, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + assert.Equal(t, uint64(42), gc.SettingsRevision) + require.Len(t, gc.Settings, 2) + + region := gc.Settings["region"] + assert.Equal(t, v1.SettingValueString, region.Type) + assert.Equal(t, "us-west-2", region.StringVal) + + maxSb := gc.Settings["max_sandboxes"] + assert.Equal(t, v1.SettingValueInt, maxSb.Type) + assert.Equal(t, int64(100), maxSb.IntVal) +} + +func TestGatewayConfigFromProto_EmptySettings(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + SettingsRevision: 1, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + assert.Equal(t, uint64(1), gc.SettingsRevision) + assert.Empty(t, gc.Settings) +} + +func TestGatewayConfigFromProto_Nil(t *testing.T) { + gc := GatewayConfigFromProto(nil) + assert.Nil(t, gc) +} + +func TestGatewayConfigFromProto_SettingsDeepCopy(t *testing.T) { + resp := &sbv1.GetGatewayConfigResponse{ + Settings: map[string]*sbv1.SettingValue{ + "key": { + Value: &sbv1.SettingValue_StringValue{StringValue: "original"}, + }, + }, + } + + gc := GatewayConfigFromProto(resp) + + require.NotNil(t, gc) + // Mutate the proto map — SDK map must not be affected. + resp.Settings["key"].Value = &sbv1.SettingValue_StringValue{StringValue: "mutated"} + assert.Equal(t, "original", gc.Settings["key"].StringVal, + "deep copy must isolate SDK settings from proto") +} + +// --- ConfigUpdate (ConfigUpdate → UpdateConfigRequest) --- + +func TestConfigUpdateToProto(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "my-sandbox", + SettingKey: "timeout", + SettingValue: &v1.SettingValue{ + Type: v1.SettingValueInt, + IntVal: 60, + }, + DeleteSetting: false, + Global: false, + ExpectedResourceVersion: 7, + Annotations: map[string]string{"source": "cli", "user": "admin"}, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.Name) + assert.Equal(t, "timeout", req.SettingKey) + require.NotNil(t, req.SettingValue) + assert.Equal(t, int64(60), req.SettingValue.GetIntValue()) + assert.False(t, req.DeleteSetting) + assert.False(t, req.Global) + assert.Equal(t, uint64(7), req.ExpectedResourceVersion) + assert.Nil(t, req.Policy) + assert.Empty(t, req.MergeOperations) + assert.Equal(t, map[string]string{"source": "cli", "user": "admin"}, req.Annotations) + + cu.Annotations["source"] = "MUTATED" + assert.Equal(t, "cli", req.Annotations["source"], "annotations must be deep copied") +} + +func TestConfigUpdateToProto_WithPolicy(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-policy", + Policy: &v1.SandboxPolicy{ + Version: 3, + Filesystem: &v1.FilesystemPolicy{ + ReadOnly: []string{"/etc"}, + }, + }, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + require.NotNil(t, req.Policy, "typed SandboxPolicy must be converted to proto") + assert.Equal(t, uint32(3), req.Policy.GetVersion()) + require.NotNil(t, req.Policy.GetFilesystem()) + assert.Equal(t, []string{"/etc"}, req.Policy.GetFilesystem().GetReadOnly()) +} + +func TestConfigUpdateToProto_WithDeleteSetting(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-del", + SettingKey: "obsolete-key", + DeleteSetting: true, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "obsolete-key", req.SettingKey) + assert.True(t, req.DeleteSetting) +} + +func TestConfigUpdateToProto_GlobalScope(t *testing.T) { + cu := &v1.ConfigUpdate{ + SettingKey: "global-setting", + SettingValue: &v1.SettingValue{ + Type: v1.SettingValueString, + StringVal: "global-val", + }, + Global: true, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.True(t, req.Global) + assert.Empty(t, req.Name) +} + +func TestConfigUpdateToProto_NilSettingValue(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-nil", + SettingKey: "key", + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Nil(t, req.SettingValue) +} + +func TestConfigUpdateToProto_Nil(t *testing.T) { + req, err := ConfigUpdateToProto(nil) + require.NoError(t, err) + assert.Nil(t, req) +} + +func TestConfigUpdateToProto_NilPolicy(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb-nil-policy", + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Nil(t, req.Policy, "nil SDK policy must produce nil proto policy") +} + +// --- ConfigUpdateResult (UpdateConfigResponse → ConfigUpdateResult) --- + +func TestConfigUpdateResultFromProto(t *testing.T) { + resp := &pb.UpdateConfigResponse{ + Version: 10, + PolicyHash: "sha256:updated", + SettingsRevision: 55, + Deleted: true, + Annotations: map[string]string{"sandbox_id": "sb-123"}, + } + + result := ConfigUpdateResultFromProto(resp) + + require.NotNil(t, result) + assert.Equal(t, uint32(10), result.Version) + assert.Equal(t, "sha256:updated", result.PolicyHash) + assert.Equal(t, uint64(55), result.SettingsRevision) + assert.True(t, result.Deleted) + assert.Equal(t, map[string]string{"sandbox_id": "sb-123"}, result.Annotations) + + resp.Annotations["sandbox_id"] = "MUTATED" + assert.Equal(t, "sb-123", result.Annotations["sandbox_id"], "annotations must be deep copied") +} + +func TestConfigUpdateResultFromProto_DefaultValues(t *testing.T) { + resp := &pb.UpdateConfigResponse{} + + result := ConfigUpdateResultFromProto(resp) + + require.NotNil(t, result) + assert.Zero(t, result.Version) + assert.Empty(t, result.PolicyHash) + assert.Zero(t, result.SettingsRevision) + assert.False(t, result.Deleted) +} + +func TestConfigUpdateResultFromProto_Nil(t *testing.T) { + result := ConfigUpdateResultFromProto(nil) + assert.Nil(t, result) +} + +// --- PolicyMergeOperationToProto --- + +func TestPolicyMergeOperationToProto_Nil(t *testing.T) { + pmo, err := PolicyMergeOperationToProto(nil) + assert.NoError(t, err) + assert.Nil(t, pmo) +} + +func TestPolicyMergeOperationToProto_Empty(t *testing.T) { + op := &v1.PolicyMergeOperation{} + _, err := PolicyMergeOperationToProto(op) + + require.Error(t, err) + assert.Contains(t, err.Error(), "got 0") +} + +func TestPolicyMergeOperationToProto_MultipleSet(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddRule: &v1.AddNetworkRule{RuleName: "r1"}, + RemoveRule: &v1.RemoveNetworkRule{RuleName: "r2"}, + } + _, err := PolicyMergeOperationToProto(op) + + require.Error(t, err) + assert.Contains(t, err.Error(), "got 2") +} + +func TestPolicyMergeOperationToProto_AddRule(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddRule: &v1.AddNetworkRule{ + RuleName: "allow-api", + Rule: v1.NetworkPolicyRule{ + Name: "allow-api", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "api.example.com", Port: 443, Protocol: "tcp"}, + }, + Binaries: []v1.PolicyNetworkBinary{ + {Path: "/usr/bin/curl"}, + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + ar := pmo.GetAddRule() + require.NotNil(t, ar, "expected AddRule variant") + assert.Equal(t, "allow-api", ar.GetRuleName()) + require.NotNil(t, ar.GetRule()) + assert.Equal(t, "allow-api", ar.GetRule().GetName()) + require.Len(t, ar.GetRule().GetEndpoints(), 1) + assert.Equal(t, "api.example.com", ar.GetRule().GetEndpoints()[0].GetHost()) + assert.Equal(t, uint32(443), ar.GetRule().GetEndpoints()[0].GetPort()) + require.Len(t, ar.GetRule().GetBinaries(), 1) + assert.Equal(t, "/usr/bin/curl", ar.GetRule().GetBinaries()[0].GetPath()) +} + +func TestPolicyMergeOperationToProto_RemoveEndpoint(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveEndpoint: &v1.RemoveNetworkEndpoint{ + RuleName: "allow-api", + Host: "old.example.com", + Port: 8080, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + re := pmo.GetRemoveEndpoint() + require.NotNil(t, re, "expected RemoveEndpoint variant") + assert.Equal(t, "allow-api", re.GetRuleName()) + assert.Equal(t, "old.example.com", re.GetHost()) + assert.Equal(t, uint32(8080), re.GetPort()) +} + +func TestPolicyMergeOperationToProto_RemoveRule(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveRule: &v1.RemoveNetworkRule{ + RuleName: "obsolete-rule", + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + rr := pmo.GetRemoveRule() + require.NotNil(t, rr, "expected RemoveRule variant") + assert.Equal(t, "obsolete-rule", rr.GetRuleName()) +} + +func TestPolicyMergeOperationToProto_AddDenyRules(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddDenyRules: &v1.AddDenyRules{ + Host: "blocked.example.com", + Port: 443, + DenyRules: []v1.L7DenyRule{ + { + Method: "POST", + Path: "/admin", + }, + { + Method: "GET", + OperationType: "query", + OperationName: "InternalData", + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + adr := pmo.GetAddDenyRules() + require.NotNil(t, adr, "expected AddDenyRules variant") + assert.Equal(t, "blocked.example.com", adr.GetHost()) + assert.Equal(t, uint32(443), adr.GetPort()) + require.Len(t, adr.GetDenyRules(), 2) + assert.Equal(t, "POST", adr.GetDenyRules()[0].GetMethod()) + assert.Equal(t, "/admin", adr.GetDenyRules()[0].GetPath()) + assert.Equal(t, "InternalData", adr.GetDenyRules()[1].GetOperationName()) +} + +func TestPolicyMergeOperationToProto_AddAllowRules(t *testing.T) { + op := &v1.PolicyMergeOperation{ + AddAllowRules: &v1.AddAllowRules{ + Host: "api.example.com", + Port: 443, + Rules: []v1.L7Rule{ + { + Allow: &v1.L7Allow{ + Method: "GET", + Path: "/health", + }, + }, + }, + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + aar := pmo.GetAddAllowRules() + require.NotNil(t, aar, "expected AddAllowRules variant") + assert.Equal(t, "api.example.com", aar.GetHost()) + assert.Equal(t, uint32(443), aar.GetPort()) + require.Len(t, aar.GetRules(), 1) + require.NotNil(t, aar.GetRules()[0].GetAllow()) + assert.Equal(t, "GET", aar.GetRules()[0].GetAllow().GetMethod()) + assert.Equal(t, "/health", aar.GetRules()[0].GetAllow().GetPath()) +} + +func TestPolicyMergeOperationToProto_RemoveBinary(t *testing.T) { + op := &v1.PolicyMergeOperation{ + RemoveBinary: &v1.RemoveNetworkBinary{ + RuleName: "allow-api", + BinaryPath: "/usr/bin/wget", + }, + } + + pmo, err := PolicyMergeOperationToProto(op) + require.NoError(t, err) + + require.NotNil(t, pmo) + rb := pmo.GetRemoveBinary() + require.NotNil(t, rb, "expected RemoveBinary variant") + assert.Equal(t, "allow-api", rb.GetRuleName()) + assert.Equal(t, "/usr/bin/wget", rb.GetBinaryPath()) +} + +// --- ConfigUpdateToProto with MergeOperations --- + +func TestConfigUpdateToProto_WithMergeOperations(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "my-sandbox", + MergeOperations: []v1.PolicyMergeOperation{ + { + RemoveRule: &v1.RemoveNetworkRule{RuleName: "old-rule"}, + }, + { + AddRule: &v1.AddNetworkRule{ + RuleName: "new-rule", + Rule: v1.NetworkPolicyRule{ + Name: "new-rule", + Endpoints: []v1.PolicyNetworkEndpoint{ + {Host: "svc.local", Port: 8080}, + }, + }, + }, + }, + { + RemoveBinary: &v1.RemoveNetworkBinary{ + RuleName: "new-rule", + BinaryPath: "/tmp/bad", + }, + }, + }, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Equal(t, "my-sandbox", req.GetName()) + require.Len(t, req.GetMergeOperations(), 3) + + // First: RemoveRule + rr := req.GetMergeOperations()[0].GetRemoveRule() + require.NotNil(t, rr) + assert.Equal(t, "old-rule", rr.GetRuleName()) + + // Second: AddRule + ar := req.GetMergeOperations()[1].GetAddRule() + require.NotNil(t, ar) + assert.Equal(t, "new-rule", ar.GetRuleName()) + require.NotNil(t, ar.GetRule()) + require.Len(t, ar.GetRule().GetEndpoints(), 1) + assert.Equal(t, "svc.local", ar.GetRule().GetEndpoints()[0].GetHost()) + + // Third: RemoveBinary + rb := req.GetMergeOperations()[2].GetRemoveBinary() + require.NotNil(t, rb) + assert.Equal(t, "/tmp/bad", rb.GetBinaryPath()) +} + +func TestConfigUpdateToProto_EmptyMergeOperations(t *testing.T) { + cu := &v1.ConfigUpdate{ + Name: "sb", + MergeOperations: []v1.PolicyMergeOperation{}, + } + + req, err := ConfigUpdateToProto(cu) + require.NoError(t, err) + + require.NotNil(t, req) + assert.Empty(t, req.GetMergeOperations()) +} + +// --- CopyByteSlice helper --- + +func TestCopyByteSlice(t *testing.T) { + original := []byte{0x01, 0x02, 0x03} + copied := CopyByteSlice(original) + + assert.Equal(t, original, copied) + + // Mutate original — copy must not be affected. + original[0] = 0xFF + assert.Equal(t, byte(0x01), copied[0], "copy must be independent of original") +} + +func TestCopyByteSlice_Nil(t *testing.T) { + copied := CopyByteSlice(nil) + assert.Nil(t, copied) +} + +func TestCopyByteSlice_Empty(t *testing.T) { + original := []byte{} + copied := CopyByteSlice(original) + assert.NotNil(t, copied) + assert.Empty(t, copied) +} diff --git a/sdk/go/openshell/v1/internal/converter/ssh.go b/sdk/go/openshell/v1/internal/converter/ssh.go new file mode 100644 index 0000000000..538c1d17f5 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/ssh.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 ( + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// SSHSessionFromProto converts a CreateSshSessionResponse to an SSHSession. +func SSHSessionFromProto(resp *pb.CreateSshSessionResponse) *v1.SSHSession { + if resp == nil { + return nil + } + return &v1.SSHSession{ + SandboxID: resp.GetSandboxId(), + Token: resp.GetToken(), + GatewayHost: resp.GetGatewayHost(), + GatewayPort: resp.GetGatewayPort(), + GatewayScheme: resp.GetGatewayScheme(), + HostKeyFingerprint: resp.GetHostKeyFingerprint(), + ExpiresAtMs: resp.GetExpiresAtMs(), + } +} + +// SSHSessionToProto converts an SSHSession to a CreateSshSessionResponse. +// This is primarily used for round-trip testing and fake implementations. +func SSHSessionToProto(session *v1.SSHSession) *pb.CreateSshSessionResponse { + if session == nil { + return nil + } + return &pb.CreateSshSessionResponse{ + SandboxId: session.SandboxID, + Token: session.Token, + GatewayHost: session.GatewayHost, + GatewayPort: session.GatewayPort, + GatewayScheme: session.GatewayScheme, + HostKeyFingerprint: session.HostKeyFingerprint, + ExpiresAtMs: session.ExpiresAtMs, + } +} diff --git a/sdk/go/openshell/v1/internal/converter/ssh_test.go b/sdk/go/openshell/v1/internal/converter/ssh_test.go new file mode 100644 index 0000000000..11ce395d75 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/ssh_test.go @@ -0,0 +1,113 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSSHSessionFromProto(t *testing.T) { + resp := &pb.CreateSshSessionResponse{ + SandboxId: "sb-123", + Token: "tok-secret", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + + session := SSHSessionFromProto(resp) + + require.NotNil(t, session) + assert.Equal(t, "sb-123", session.SandboxID) + assert.Equal(t, "tok-secret", session.Token) + assert.Equal(t, "gw.example.com", session.GatewayHost) + assert.Equal(t, uint32(2222), session.GatewayPort) + assert.Equal(t, "https", session.GatewayScheme) + assert.Equal(t, "SHA256:abc123", session.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), session.ExpiresAtMs) +} + +func TestSSHSessionFromProto_MinimalFields(t *testing.T) { + resp := &pb.CreateSshSessionResponse{ + SandboxId: "sb-min", + Token: "tok-min", + GatewayHost: "localhost", + GatewayPort: 22, + } + + session := SSHSessionFromProto(resp) + + require.NotNil(t, session) + assert.Equal(t, "sb-min", session.SandboxID) + assert.Equal(t, "tok-min", session.Token) + assert.Equal(t, "localhost", session.GatewayHost) + assert.Equal(t, uint32(22), session.GatewayPort) + assert.Empty(t, session.GatewayScheme) + assert.Empty(t, session.HostKeyFingerprint) + assert.Zero(t, session.ExpiresAtMs) +} + +func TestSSHSessionFromProto_Nil(t *testing.T) { + session := SSHSessionFromProto(nil) + assert.Nil(t, session) +} + +func TestSSHSessionToProto(t *testing.T) { + session := &v1.SSHSession{ + SandboxID: "sb-123", + Token: "tok-secret", + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + + resp := SSHSessionToProto(session) + + require.NotNil(t, resp) + assert.Equal(t, "sb-123", resp.SandboxId) + assert.Equal(t, "tok-secret", resp.Token) + assert.Equal(t, "gw.example.com", resp.GatewayHost) + assert.Equal(t, uint32(2222), resp.GatewayPort) + assert.Equal(t, "https", resp.GatewayScheme) + assert.Equal(t, "SHA256:abc123", resp.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), resp.ExpiresAtMs) +} + +func TestSSHSessionToProto_Nil(t *testing.T) { + resp := SSHSessionToProto(nil) + assert.Nil(t, resp) +} + +func TestSSHSessionRoundTrip(t *testing.T) { + original := &v1.SSHSession{ + SandboxID: "sb-rt", + Token: "tok-rt", + GatewayHost: "rt.example.com", + GatewayPort: 443, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:roundtrip", + ExpiresAtMs: 1800000000000, + } + + proto := SSHSessionToProto(original) + back := SSHSessionFromProto(proto) + + require.NotNil(t, back) + assert.Equal(t, original.SandboxID, back.SandboxID) + assert.Equal(t, original.Token, back.Token) + assert.Equal(t, original.GatewayHost, back.GatewayHost) + assert.Equal(t, original.GatewayPort, back.GatewayPort) + assert.Equal(t, original.GatewayScheme, back.GatewayScheme) + assert.Equal(t, original.HostKeyFingerprint, back.HostKeyFingerprint) + assert.Equal(t, original.ExpiresAtMs, back.ExpiresAtMs) +} diff --git a/sdk/go/openshell/v1/internal/converter/time_test.go b/sdk/go/openshell/v1/internal/converter/time_test.go index 0b4d44fd2f..854fe98816 100644 --- a/sdk/go/openshell/v1/internal/converter/time_test.go +++ b/sdk/go/openshell/v1/internal/converter/time_test.go @@ -11,7 +11,7 @@ import ( ) func TestTimeFromMillis(t *testing.T) { - ms := int64(1719475200000) // 2024-06-27T08:00:00Z + ms := int64(1719475200000) // 2024-06-27T12:00:00Z tm := TimeFromMillis(ms) assert.Equal(t, 2024, tm.Year()) assert.Equal(t, time.June, tm.Month()) diff --git a/sdk/go/openshell/v1/internal/converter/workspace.go b/sdk/go/openshell/v1/internal/converter/workspace.go new file mode 100644 index 0000000000..80925a338b --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/workspace.go @@ -0,0 +1,97 @@ +// 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" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" +) + +// WorkspaceFromProto converts a proto Workspace to an SDK Workspace. +func WorkspaceFromProto(w *dm.Workspace) *types.Workspace { + if w == nil { + return nil + } + + result := &types.Workspace{} + + if m := w.GetMetadata(); m != nil { + result.ID = m.GetId() + result.Name = m.GetName() + result.CreatedAt = TimeFromMillis(m.GetCreatedAtMs()) + result.Labels = CopyStringMap(m.GetLabels()) + result.Annotations = CopyStringMap(m.GetAnnotations()) + result.ResourceVersion = m.GetResourceVersion() + result.Workspace = m.GetWorkspace() + result.DeletionTimestamp = TimeFromMillisPtr(m.GetDeletionTimestampMs()) + } + + if status := w.GetStatus(); status != nil { + result.Phase = WorkspacePhaseFromProto(status.GetPhase()) + } else { + result.Phase = types.WorkspaceUnknown + } + + return result +} + +// WorkspacePhaseFromProto converts a proto WorkspacePhase to an SDK WorkspacePhase. +func WorkspacePhaseFromProto(phase dm.WorkspacePhase) types.WorkspacePhase { + switch phase { + case dm.WorkspacePhase_WORKSPACE_PHASE_ACTIVE: + return types.WorkspaceActive + case dm.WorkspacePhase_WORKSPACE_PHASE_TERMINATING: + return types.WorkspaceTerminating + default: + return types.WorkspaceUnknown + } +} + +// WorkspaceMemberFromProto converts a proto WorkspaceMember to an SDK WorkspaceMember. +func WorkspaceMemberFromProto(m *pb.WorkspaceMember) *types.WorkspaceMember { + if m == nil { + return nil + } + + result := &types.WorkspaceMember{ + PrincipalSubject: m.GetPrincipalSubject(), + Role: WorkspaceRoleFromProto(m.GetRole()), + } + + if meta := m.GetMetadata(); meta != nil { + result.ID = meta.GetId() + result.Name = meta.GetName() + result.CreatedAt = TimeFromMillis(meta.GetCreatedAtMs()) + result.Labels = CopyStringMap(meta.GetLabels()) + result.Annotations = CopyStringMap(meta.GetAnnotations()) + result.ResourceVersion = meta.GetResourceVersion() + } + + return result +} + +// WorkspaceRoleFromProto converts a proto WorkspaceRole to an SDK WorkspaceRole. +func WorkspaceRoleFromProto(role pb.WorkspaceRole) types.WorkspaceRole { + switch role { + case pb.WorkspaceRole_WORKSPACE_ROLE_ADMIN: + return types.WorkspaceRoleAdmin + case pb.WorkspaceRole_WORKSPACE_ROLE_USER: + return types.WorkspaceRoleUser + default: + return types.WorkspaceRoleUnknown + } +} + +// WorkspaceRoleToProto converts an SDK WorkspaceRole to a proto WorkspaceRole. +func WorkspaceRoleToProto(role types.WorkspaceRole) pb.WorkspaceRole { + switch role { + case types.WorkspaceRoleAdmin: + return pb.WorkspaceRole_WORKSPACE_ROLE_ADMIN + case types.WorkspaceRoleUser: + return pb.WorkspaceRole_WORKSPACE_ROLE_USER + default: + return pb.WorkspaceRole_WORKSPACE_ROLE_UNSPECIFIED + } +} diff --git a/sdk/go/openshell/v1/internal/converter/workspace_test.go b/sdk/go/openshell/v1/internal/converter/workspace_test.go new file mode 100644 index 0000000000..e86ec443c0 --- /dev/null +++ b/sdk/go/openshell/v1/internal/converter/workspace_test.go @@ -0,0 +1,209 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package converter + +import ( + "testing" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestWorkspaceFromProto(t *testing.T) { + proto := &dm.Workspace{ + Metadata: &dm.ObjectMeta{ + Id: "ws-1", + Name: "my-workspace", + CreatedAtMs: 1700000000000, + Labels: map[string]string{"team": "platform"}, + Annotations: map[string]string{"managed-by": "sdk"}, + ResourceVersion: 3, + Workspace: "", + DeletionTimestampMs: 1700000060000, + }, + Status: &dm.WorkspaceStatus{ + Phase: dm.WorkspacePhase_WORKSPACE_PHASE_ACTIVE, + }, + } + + ws := WorkspaceFromProto(proto) + + require.NotNil(t, ws) + assert.Equal(t, "ws-1", ws.ID) + assert.Equal(t, "my-workspace", ws.Name) + assert.Equal(t, time.UnixMilli(1700000000000).UTC(), ws.CreatedAt) + assert.Equal(t, map[string]string{"team": "platform"}, ws.Labels) + assert.Equal(t, map[string]string{"managed-by": "sdk"}, ws.Annotations) + assert.Equal(t, uint64(3), ws.ResourceVersion) + assert.Equal(t, "", ws.Workspace) + require.NotNil(t, ws.DeletionTimestamp) + assert.Equal(t, time.UnixMilli(1700000060000).UTC(), *ws.DeletionTimestamp) + assert.Equal(t, v1.WorkspaceActive, ws.Phase) +} + +func TestWorkspaceFromProto_DeepCopy(t *testing.T) { + labels := map[string]string{"env": "test"} + proto := &dm.Workspace{ + Metadata: &dm.ObjectMeta{ + Name: "ws-copy", + Labels: labels, + }, + Status: &dm.WorkspaceStatus{ + Phase: dm.WorkspacePhase_WORKSPACE_PHASE_ACTIVE, + }, + } + + ws := WorkspaceFromProto(proto) + labels["env"] = "mutated" + + assert.Equal(t, "test", ws.Labels["env"]) +} + +func TestWorkspaceFromProto_NilMetadata(t *testing.T) { + proto := &dm.Workspace{ + Status: &dm.WorkspaceStatus{ + Phase: dm.WorkspacePhase_WORKSPACE_PHASE_TERMINATING, + }, + } + + ws := WorkspaceFromProto(proto) + + require.NotNil(t, ws) + assert.Empty(t, ws.ID) + assert.Equal(t, v1.WorkspaceTerminating, ws.Phase) +} + +func TestWorkspaceFromProto_NilStatus(t *testing.T) { + proto := &dm.Workspace{ + Metadata: &dm.ObjectMeta{Name: "ws-nostatus"}, + } + + ws := WorkspaceFromProto(proto) + + require.NotNil(t, ws) + assert.Equal(t, v1.WorkspaceUnknown, ws.Phase) +} + +func TestWorkspaceFromProto_Nil(t *testing.T) { + ws := WorkspaceFromProto(nil) + assert.Nil(t, ws) +} + +func TestWorkspacePhaseFromProto(t *testing.T) { + tests := []struct { + proto dm.WorkspacePhase + expected v1.WorkspacePhase + }{ + {dm.WorkspacePhase_WORKSPACE_PHASE_ACTIVE, v1.WorkspaceActive}, + {dm.WorkspacePhase_WORKSPACE_PHASE_TERMINATING, v1.WorkspaceTerminating}, + {dm.WorkspacePhase_WORKSPACE_PHASE_UNSPECIFIED, v1.WorkspaceUnknown}, + {dm.WorkspacePhase(99), v1.WorkspaceUnknown}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, WorkspacePhaseFromProto(tt.proto)) + } +} + +func TestWorkspaceMemberFromProto(t *testing.T) { + proto := &pb.WorkspaceMember{ + Metadata: &dm.ObjectMeta{ + Id: "mem-1", + Name: "member-auto-name", + CreatedAtMs: 1700000000000, + Annotations: map[string]string{"source": "cli"}, + ResourceVersion: 2, + }, + PrincipalSubject: "user@example.com", + Role: pb.WorkspaceRole_WORKSPACE_ROLE_ADMIN, + } + + m := WorkspaceMemberFromProto(proto) + + require.NotNil(t, m) + assert.Equal(t, "mem-1", m.ID) + assert.Equal(t, "member-auto-name", m.Name) + assert.Equal(t, time.UnixMilli(1700000000000).UTC(), m.CreatedAt) + assert.Equal(t, map[string]string{"source": "cli"}, m.Annotations) + assert.Equal(t, uint64(2), m.ResourceVersion) + assert.Equal(t, "user@example.com", m.PrincipalSubject) + assert.Equal(t, v1.WorkspaceRoleAdmin, m.Role) +} + +func TestWorkspaceMemberFromProto_DeepCopy(t *testing.T) { + annotations := map[string]string{"key": "original"} + proto := &pb.WorkspaceMember{ + Metadata: &dm.ObjectMeta{ + Annotations: annotations, + }, + PrincipalSubject: "user@test.com", + Role: pb.WorkspaceRole_WORKSPACE_ROLE_USER, + } + + m := WorkspaceMemberFromProto(proto) + annotations["key"] = "mutated" + + assert.Equal(t, "original", m.Annotations["key"]) +} + +func TestWorkspaceMemberFromProto_NilMetadata(t *testing.T) { + proto := &pb.WorkspaceMember{ + PrincipalSubject: "user@test.com", + Role: pb.WorkspaceRole_WORKSPACE_ROLE_USER, + } + + m := WorkspaceMemberFromProto(proto) + + require.NotNil(t, m) + assert.Empty(t, m.ID) + assert.Equal(t, "user@test.com", m.PrincipalSubject) + assert.Equal(t, v1.WorkspaceRoleUser, m.Role) +} + +func TestWorkspaceMemberFromProto_Nil(t *testing.T) { + m := WorkspaceMemberFromProto(nil) + assert.Nil(t, m) +} + +func TestWorkspaceRoleFromProto(t *testing.T) { + tests := []struct { + proto pb.WorkspaceRole + expected v1.WorkspaceRole + }{ + {pb.WorkspaceRole_WORKSPACE_ROLE_ADMIN, v1.WorkspaceRoleAdmin}, + {pb.WorkspaceRole_WORKSPACE_ROLE_USER, v1.WorkspaceRoleUser}, + {pb.WorkspaceRole_WORKSPACE_ROLE_UNSPECIFIED, v1.WorkspaceRoleUnknown}, + {pb.WorkspaceRole(99), v1.WorkspaceRoleUnknown}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, WorkspaceRoleFromProto(tt.proto)) + } +} + +func TestWorkspaceRoleToProto(t *testing.T) { + tests := []struct { + sdk v1.WorkspaceRole + expected pb.WorkspaceRole + }{ + {v1.WorkspaceRoleAdmin, pb.WorkspaceRole_WORKSPACE_ROLE_ADMIN}, + {v1.WorkspaceRoleUser, pb.WorkspaceRole_WORKSPACE_ROLE_USER}, + {v1.WorkspaceRole("invalid"), pb.WorkspaceRole_WORKSPACE_ROLE_UNSPECIFIED}, + } + + for _, tt := range tests { + assert.Equal(t, tt.expected, WorkspaceRoleToProto(tt.sdk)) + } +} + +func TestWorkspaceRoleRoundTrip(t *testing.T) { + roles := []v1.WorkspaceRole{v1.WorkspaceRoleAdmin, v1.WorkspaceRoleUser} + for _, role := range roles { + assert.Equal(t, role, WorkspaceRoleFromProto(WorkspaceRoleToProto(role))) + } +} diff --git a/sdk/go/openshell/v1/internal/grpc/conn.go b/sdk/go/openshell/v1/internal/grpc/conn.go index e2198546a2..43599cf495 100644 --- a/sdk/go/openshell/v1/internal/grpc/conn.go +++ b/sdk/go/openshell/v1/internal/grpc/conn.go @@ -39,6 +39,9 @@ func NewConnection(address string, tlsCfg *TLSParams, auth credentials.PerRPCCre opts := []grpc.DialOption{} if usePlaintext { + if tlsCfg != nil && (tlsCfg.CAFile != "" || tlsCfg.CertFile != "" || tlsCfg.KeyFile != "") { + return nil, fmt.Errorf("grpc connect: TLS parameters (CAFile/CertFile/KeyFile) are ignored with plaintext (http://) address") + } opts = append(opts, grpc.WithTransportCredentials(insecure.NewCredentials())) } else if tlsCfg != nil { creds, err := buildTLSCredentials(tlsCfg) diff --git a/sdk/go/openshell/v1/internal/grpc/conn_test.go b/sdk/go/openshell/v1/internal/grpc/conn_test.go index a1da2883e8..6f03003fbf 100644 --- a/sdk/go/openshell/v1/internal/grpc/conn_test.go +++ b/sdk/go/openshell/v1/internal/grpc/conn_test.go @@ -8,15 +8,14 @@ import ( "net" "testing" + "github.com/stretchr/testify/require" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) func TestNewConnectionHTTPSchemeUsesPlaintext(t *testing.T) { lis, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } + require.NoError(t, err) defer func() { _ = lis.Close() }() srv := grpc.NewServer() @@ -24,55 +23,37 @@ func TestNewConnectionHTTPSchemeUsesPlaintext(t *testing.T) { defer srv.Stop() conn, err := NewConnection("http://"+lis.Addr().String(), nil, nil) - if err != nil { - t.Fatalf("NewConnection with http:// scheme failed: %v", err) - } + require.NoError(t, err) defer func() { _ = conn.Close() }() } func TestNewConnectionHTTPSSchemeUsesTLS(t *testing.T) { - // https:// with nil TLS config should default to system TLS. - // We cannot dial a real TLS server here, but we can verify the - // connection is created (it will fail on handshake, not on dial). conn, err := NewConnection("https://127.0.0.1:1", nil, nil) - if err != nil { - t.Fatalf("NewConnection with https:// scheme should not fail on create: %v", err) - } + require.NoError(t, err) defer func() { _ = conn.Close() }() } func TestNewConnectionNoSchemeUsesTLS(t *testing.T) { conn, err := NewConnection("127.0.0.1:1", nil, nil) - if err != nil { - t.Fatalf("NewConnection without scheme should not fail on create: %v", err) - } + require.NoError(t, err) defer func() { _ = conn.Close() }() } func TestNewConnectionInsecureTLSConfig(t *testing.T) { - // Insecure: true means TLS with InsecureSkipVerify, not plaintext. - // We can verify the connection is created (handshake will fail since - // the server is not TLS, but NewClient itself should succeed). conn, err := NewConnection("127.0.0.1:1", &TLSParams{Insecure: true}, nil) - if err != nil { - t.Fatalf("NewConnection with Insecure TLS config failed: %v", err) - } + require.NoError(t, err) defer func() { _ = conn.Close() }() } func TestNewConnectionHTTPWithSecureAuthRejects(t *testing.T) { auth := &testTokenAuth{token: "dev-token", requireSecurity: true} _, err := NewConnection("http://127.0.0.1:1", nil, auth) - if err == nil { - t.Fatal("expected error when using http:// with auth that requires transport security") - } + require.Error(t, err) } func TestNewConnectionHTTPWithInsecureAuth(t *testing.T) { lis, err := net.Listen("tcp", "127.0.0.1:0") - if err != nil { - t.Fatalf("listen: %v", err) - } + require.NoError(t, err) defer func() { _ = lis.Close() }() srv := grpc.NewServer(grpc.Creds(insecure.NewCredentials())) @@ -81,9 +62,27 @@ func TestNewConnectionHTTPWithInsecureAuth(t *testing.T) { auth := &testTokenAuth{token: "dev-token", requireSecurity: false} conn, err := NewConnection("http://"+lis.Addr().String(), nil, auth) - if err != nil { - t.Fatalf("NewConnection with http:// + insecure auth failed: %v", err) - } + require.NoError(t, err) + defer func() { _ = conn.Close() }() +} + +func TestNewConnectionHTTPWithTLSParamsRejects(t *testing.T) { + _, err := NewConnection("http://127.0.0.1:1", &TLSParams{CAFile: "/some/ca.pem"}, nil) + require.Error(t, err) + require.Contains(t, err.Error(), "TLS parameters") +} + +func TestNewConnectionHTTPWithEmptyTLSParamsAllowed(t *testing.T) { + lis, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + defer func() { _ = lis.Close() }() + + srv := grpc.NewServer() + go func() { _ = srv.Serve(lis) }() + defer srv.Stop() + + conn, err := NewConnection("http://"+lis.Addr().String(), &TLSParams{}, nil) + require.NoError(t, err) defer func() { _ = conn.Close() }() } diff --git a/sdk/go/openshell/v1/oidc/authcode.go b/sdk/go/openshell/v1/oidc/authcode.go new file mode 100644 index 0000000000..0bde13db55 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/authcode.go @@ -0,0 +1,236 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" + + "golang.org/x/oauth2" +) + +// generateCodeVerifier creates a PKCE code verifier per RFC 7636. +// It generates 32 random bytes and encodes them as base64url without +// padding, producing a 43-character string. +func generateCodeVerifier() (string, error) { + b := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("generate code verifier: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// codeChallengeS256 computes the S256 PKCE code challenge for the +// given verifier. It returns BASE64URL(SHA256(verifier)) without +// padding, as specified in RFC 7636 Section 4.2. +func codeChallengeS256(verifier string) string { + h := sha256.Sum256([]byte(verifier)) + return base64.RawURLEncoding.EncodeToString(h[:]) +} + +// generateState creates a cryptographic state parameter for the +// authorization request. It generates 16 random bytes encoded as +// base64url without padding. +func generateState() (string, error) { + b := make([]byte, 16) + if _, err := io.ReadFull(rand.Reader, b); err != nil { + return "", fmt.Errorf("generate state: %w", err) + } + return base64.RawURLEncoding.EncodeToString(b), nil +} + +// buildAuthURL constructs the authorization endpoint URL with query +// parameters for the authorization code flow. If challenge is empty, +// PKCE parameters are omitted (for providers that do not support it). +func buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge string, scopes []string) string { + u, err := url.Parse(authEndpoint) + if err != nil || u.Scheme == "" { + u = &url.URL{Path: authEndpoint} + } + q := u.Query() + q.Set("response_type", "code") + q.Set("client_id", clientID) + q.Set("redirect_uri", redirectURI) + q.Set("state", state) + q.Set("scope", strings.Join(scopes, " ")) + + if challenge != "" { + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + } + + u.RawQuery = q.Encode() + return u.String() +} + +// callbackResult carries the authorization code (or error) from the +// callback server to the auth code flow orchestrator. +type callbackResult struct { + code string + err error +} + +// startCallbackServer starts a localhost HTTP server to receive the +// OIDC provider's authorization callback. It listens on the specified +// port (use 0 for OS-assigned port). The server handles a single +// callback request and sends the result on the returned channel. +// +// The caller is responsible for calling srv.Close() when done. +func startCallbackServer(ctx context.Context, port int, expectedState string) (*http.Server, <-chan callbackResult, error) { + resultCh := make(chan callbackResult, 1) + var sendOnce sync.Once + send := func(r callbackResult) { + sendOnce.Do(func() { resultCh <- r }) + } + + mux := http.NewServeMux() + mux.HandleFunc("/callback", func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + if errCode := q.Get("error"); errCode != "" { + desc := q.Get("error_description") + msg := fmt.Sprintf("provider error: %s", errCode) + if desc != "" { + msg += ": " + desc + } + http.Error(w, msg, http.StatusBadRequest) + send(callbackResult{err: fmt.Errorf("%w: %s", ErrAuthCode, msg)}) + return + } + + state := q.Get("state") + if state != expectedState { + http.Error(w, "state mismatch", http.StatusBadRequest) + send(callbackResult{err: fmt.Errorf("%w: state mismatch", ErrAuthCode)}) + return + } + + code := q.Get("code") + if code == "" { + http.Error(w, "missing authorization code", http.StatusBadRequest) + send(callbackResult{err: fmt.Errorf("%w: missing authorization code in callback", ErrAuthCode)}) + return + } + + w.Header().Set("Content-Type", "text/html") + _, _ = fmt.Fprint(w, "

Login successful

You can close this window.

") + send(callbackResult{code: code}) + }) + + listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", port)) + if err != nil { + return nil, nil, fmt.Errorf("%w: failed to start callback server on port %d: %v", ErrCallbackServer, port, err) + } + + srv := &http.Server{ + Addr: listener.Addr().String(), + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + ReadTimeout: 10 * time.Second, + WriteTimeout: 10 * time.Second, + IdleTimeout: 30 * time.Second, + } + + done := make(chan struct{}) + go func() { + _ = srv.Serve(listener) + close(done) + }() + + go func() { + select { + case <-ctx.Done(): + case <-done: + return + } + shutdownCtx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + _ = srv.Shutdown(shutdownCtx) + }() + + return srv, resultCh, nil +} + +// tokenResponse is the JSON structure returned by the token endpoint. +type tokenResponse struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` +} + +// exchangeCode exchanges an authorization code for tokens at the +// token endpoint. If codeVerifier is empty, the PKCE code_verifier +// parameter is omitted from the request. +// +// Secrets (code, verifier) are never included in error messages. +func exchangeCode(ctx context.Context, tokenEndpoint, clientID, code, redirectURI, codeVerifier string) (*oauth2.Token, error) { + data := url.Values{ + "grant_type": {"authorization_code"}, + "code": {code}, + "client_id": {clientID}, + "redirect_uri": {redirectURI}, + } + if codeVerifier != "" { + data.Set("code_verifier", codeVerifier) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create token request: %v", ErrAuthCode, err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := oidcHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: token request failed: %v", ErrAuthCode, err) + } + defer func() { _ = resp.Body.Close() }() + + const maxResponseBytes = 1 << 20 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("%w: failed to read token response: %v", ErrAuthCode, err) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, fmt.Errorf("%w: invalid token response JSON: %v", ErrAuthCode, err) + } + + if resp.StatusCode != http.StatusOK || tokResp.Error != "" { + msg := "token exchange failed" + if tokResp.Error != "" { + msg = fmt.Sprintf("token exchange failed: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + } + return nil, fmt.Errorf("%w: %s", ErrAuthCode, msg) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/authcode_test.go b/sdk/go/openshell/v1/oidc/authcode_test.go new file mode 100644 index 0000000000..5b7d08abd1 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/authcode_test.go @@ -0,0 +1,371 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- T012: PKCE verifier/challenge generation tests --- + +func TestGenerateCodeVerifier_Length(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + // RFC 7636 requires 43-128 characters. Our implementation uses 32 + // random bytes -> 43 base64url characters (no padding). + assert.GreaterOrEqual(t, len(verifier), 43) + assert.LessOrEqual(t, len(verifier), 128) +} + +func TestGenerateCodeVerifier_Base64URLSafe(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + // Must contain only base64url characters (A-Z, a-z, 0-9, -, _). + // No padding (=) allowed per RFC 7636. + for _, c := range verifier { + valid := (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z') || + (c >= '0' && c <= '9') || + c == '-' || c == '_' + assert.True(t, valid, "invalid character in verifier: %c", c) + } +} + +func TestGenerateCodeVerifier_Unique(t *testing.T) { + v1, err := generateCodeVerifier() + require.NoError(t, err) + + v2, err := generateCodeVerifier() + require.NoError(t, err) + + assert.NotEqual(t, v1, v2, "two verifiers should differ (random)") +} + +func TestCodeChallengeS256(t *testing.T) { + // RFC 7636 Appendix B test vector: + // verifier = "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + // challenge = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM" + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := codeChallengeS256(verifier) + assert.Equal(t, "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM", challenge) +} + +func TestCodeChallengeS256_NoPadding(t *testing.T) { + verifier, err := generateCodeVerifier() + require.NoError(t, err) + + challenge := codeChallengeS256(verifier) + + // S256 challenge must be base64url without padding. + assert.NotContains(t, challenge, "=") + assert.NotContains(t, challenge, "+") + assert.NotContains(t, challenge, "/") +} + +// --- T013: Auth code flow tests --- + +func TestGenerateState_Length(t *testing.T) { + state, err := generateState() + require.NoError(t, err) + + // 16 random bytes -> 22 base64url chars (no padding). + assert.GreaterOrEqual(t, len(state), 16) +} + +func TestGenerateState_Unique(t *testing.T) { + s1, err := generateState() + require.NoError(t, err) + + s2, err := generateState() + require.NoError(t, err) + + assert.NotEqual(t, s1, s2) +} + +func TestBuildAuthURL(t *testing.T) { + authEndpoint := "https://auth.example.com/authorize" + clientID := "test-client" + redirectURI := "http://localhost:8000/callback" + state := "random-state" + verifier := "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk" + challenge := codeChallengeS256(verifier) + scopes := []string{"openid", "profile"} + + authURL := buildAuthURL(authEndpoint, clientID, redirectURI, state, challenge, scopes) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + q := parsed.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Equal(t, clientID, q.Get("client_id")) + assert.Equal(t, redirectURI, q.Get("redirect_uri")) + assert.Equal(t, state, q.Get("state")) + assert.Equal(t, challenge, q.Get("code_challenge")) + assert.Equal(t, "S256", q.Get("code_challenge_method")) + assert.Equal(t, "openid profile", q.Get("scope")) +} + +func TestBuildAuthURL_NoPKCE(t *testing.T) { + authURL := buildAuthURL( + "https://auth.example.com/authorize", + "test-client", + "http://localhost:8000/callback", + "state", + "", // empty challenge = no PKCE + []string{"openid"}, + ) + + parsed, err := url.Parse(authURL) + require.NoError(t, err) + + q := parsed.Query() + assert.Equal(t, "code", q.Get("response_type")) + assert.Empty(t, q.Get("code_challenge"), "no PKCE when challenge is empty") + assert.Empty(t, q.Get("code_challenge_method")) +} + +func TestStartCallbackServer_ReceivesCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state-123" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + // Extract the port from the server's listener address. + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?code=auth-code-xyz&state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + assert.Equal(t, http.StatusOK, resp.StatusCode) + + result := <-resultCh + require.NoError(t, result.err) + assert.Equal(t, "auth-code-xyz", result.code) +} + +func TestStartCallbackServer_StateMismatch(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "expected-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?code=some-code&state=wrong-state", addr) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) + assert.Contains(t, result.err.Error(), "state") +} + +func TestStartCallbackServer_MissingCode(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) +} + +func TestStartCallbackServer_ProviderError(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + state := "test-state" + srv, resultCh, err := startCallbackServer(ctx, 0, state) + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + addr := srv.Addr + callbackURL := fmt.Sprintf("http://%s/callback?error=access_denied&error_description=user+denied&state=%s", addr, state) + + resp, err := http.Get(callbackURL) + require.NoError(t, err) + _ = resp.Body.Close() + + result := <-resultCh + require.Error(t, result.err) + assert.True(t, errors.Is(result.err, ErrAuthCode)) + assert.Contains(t, result.err.Error(), "access_denied") +} + +func TestStartCallbackServer_SpecificPort(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + // Port 0 tells OS to pick a free port. We just verify it works. + srv, _, err := startCallbackServer(ctx, 0, "state") + require.NoError(t, err) + defer func() { _ = srv.Close() }() + + assert.NotEmpty(t, srv.Addr) +} + +func TestExchangeCode_Success(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.Equal(t, http.MethodPost, r.Method) + require.NoError(t, r.ParseForm()) + + assert.Equal(t, "authorization_code", r.Form.Get("grant_type")) + assert.Equal(t, "test-code", r.Form.Get("code")) + assert.Equal(t, "test-client", r.Form.Get("client_id")) + assert.Equal(t, "http://localhost:8000/callback", r.Form.Get("redirect_uri")) + assert.NotEmpty(t, r.Form.Get("code_verifier"), "PKCE verifier should be sent") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "at-123", + "refresh_token": "rt-456", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer tokenSrv.Close() + + tok, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "test-client", + "test-code", + "http://localhost:8000/callback", + "pkce-verifier", + ) + require.NoError(t, err) + assert.Equal(t, "at-123", tok.AccessToken) + assert.Equal(t, "rt-456", tok.RefreshToken) + assert.Equal(t, "Bearer", tok.TokenType) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestExchangeCode_NoPKCE(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + assert.Empty(t, r.Form.Get("code_verifier"), "no PKCE verifier when empty") + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "access_token": "at-no-pkce", + "token_type": "Bearer", + "expires_in": 3600 + }`)) + })) + defer tokenSrv.Close() + + tok, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "test-client", + "test-code", + "http://localhost:8000/callback", + "", // empty verifier = no PKCE + ) + require.NoError(t, err) + assert.Equal(t, "at-no-pkce", tok.AccessToken) +} + +func TestExchangeCode_ErrorResponse(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant", "error_description": "code expired"}`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "bad-code", + "http://localhost/callback", + "verifier", + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestExchangeCode_SecretsNotInError(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error": "invalid_grant"}`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "secret-code-value", + "http://localhost/callback", + "secret-verifier-value", + ) + require.Error(t, err) + // The error message must not contain the auth code or verifier. + assert.NotContains(t, err.Error(), "secret-code-value") + assert.NotContains(t, err.Error(), "secret-verifier-value") +} + +func TestExchangeCode_InvalidJSON(t *testing.T) { + tokenSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`not json`)) + })) + defer tokenSrv.Close() + + _, err := exchangeCode( + context.Background(), + tokenSrv.URL+"/token", + "client", + "code", + "http://localhost/callback", + "verifier", + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +// tokenResponseJSON is a helper for creating token endpoint responses. +func tokenResponseJSON(accessToken, refreshToken string, expiresIn int) string { + return fmt.Sprintf(`{ + "access_token": %q, + "refresh_token": %q, + "token_type": "Bearer", + "expires_in": %d + }`, accessToken, refreshToken, expiresIn) +} diff --git a/sdk/go/openshell/v1/oidc/browser.go b/sdk/go/openshell/v1/oidc/browser.go new file mode 100644 index 0000000000..6710f3b3da --- /dev/null +++ b/sdk/go/openshell/v1/oidc/browser.go @@ -0,0 +1,59 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "fmt" + "net/url" + "os/exec" + "runtime" +) + +// browserCommand returns the platform-specific command name and +// arguments for opening a URL in the user's default browser. +func browserCommand(url string) (string, []string) { + return browserCommandForOS(runtime.GOOS, url) +} + +func browserCommandForOS(goos, url string) (string, []string) { + switch goos { + case "darwin": + return "open", []string{url} + case "linux": + return "xdg-open", []string{url} + case "windows": + // Invoke the URL handler directly. Passing an authorization URL to + // cmd.exe would allow '&' and other shell metacharacters in its query + // string to be interpreted as commands. + return "rundll32", []string{"url.dll,FileProtocolHandler", url} + default: + // Fallback: try xdg-open (common on Unix-like systems). + return "xdg-open", []string{url} + } +} + +// openBrowser attempts to open the given URL in the user's default +// browser using the platform-appropriate command. Returns an error if +// the browser could not be launched. +func openBrowser(rawURL string) error { + parsed, err := url.Parse(rawURL) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") { + return fmt.Errorf("refusing to open non-HTTP URL: %s", rawURL) + } + name, args := browserCommand(rawURL) + return openBrowserWith(name, args...) +} + +// openBrowserWith runs the given command with the provided arguments. +// This is separated from openBrowser to allow testing with arbitrary +// command names. +func openBrowserWith(name string, args ...string) error { + cmd := exec.Command(name, args...) + if err := cmd.Start(); err != nil { + return fmt.Errorf("failed to open browser with %s: %w", name, err) + } + // We don't wait for the browser process to exit. It runs + // independently, and we only care that it launched. + return nil +} diff --git a/sdk/go/openshell/v1/oidc/browser_test.go b/sdk/go/openshell/v1/oidc/browser_test.go new file mode 100644 index 0000000000..714767c93d --- /dev/null +++ b/sdk/go/openshell/v1/oidc/browser_test.go @@ -0,0 +1,55 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "runtime" + "slices" + "testing" + + "github.com/stretchr/testify/assert" +) + +// T015: Browser opener tests + +func TestBrowserCommand_Platform(t *testing.T) { + name, args := browserCommand("https://example.com/auth") + + switch runtime.GOOS { + case "darwin": + assert.Equal(t, "open", name) + assert.Equal(t, []string{"https://example.com/auth"}, args) + case "linux": + assert.Equal(t, "xdg-open", name) + assert.Equal(t, []string{"https://example.com/auth"}, args) + case "windows": + assert.Equal(t, "cmd", name) + assert.Contains(t, args, "/c") + assert.Contains(t, args, "start") + default: + // Unknown platform should still return something (even if it fails). + assert.NotEmpty(t, name) + } +} + +func TestBrowserCommand_URLPassedAsArg(t *testing.T) { + testURL := "https://auth.example.com/authorize?client_id=test&state=abc" + _, args := browserCommand(testURL) + + assert.True(t, slices.Contains(args, testURL), "URL should be passed as an argument to the browser command") +} + +func TestBrowserCommandForOS_WindowsDoesNotUseCommandShell(t *testing.T) { + name, args := browserCommandForOS("windows", "https://auth.example.com/authorize?client_id=test&state=abc") + + assert.NotEqual(t, "cmd", name) + assert.NotContains(t, args, "/c") +} + +func TestOpenBrowser_InvalidCommand(t *testing.T) { + // Attempting to open a browser with a non-existent command should + // return an error rather than panic. + err := openBrowserWith("nonexistent-browser-cmd-that-does-not-exist", "https://example.com") + assert.Error(t, err, "should fail when the browser command does not exist") +} diff --git a/sdk/go/openshell/v1/oidc/credentials.go b/sdk/go/openshell/v1/oidc/credentials.go new file mode 100644 index 0000000000..b88fe7e3df --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials.go @@ -0,0 +1,150 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// ClientCredentials performs a non-interactive OAuth2 client credentials +// grant (RFC 6749 Section 4.4). It requires [WithIssuer], [WithClientID], +// and [WithClientSecret] (or [WithGateway] combined with [WithClientSecret]). +// +// This flow is intended for service accounts and machine-to-machine +// authentication. No user interaction occurs. The returned token +// typically contains only an access token (no refresh token). +// +// The client secret is never included in error messages (FR-014). +func ClientCredentials(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Client credentials should not send interactive scopes by default. + // Only send scopes if the caller explicitly set them via WithScopes. + if !cfg.scopesSet { + cfg.scopes = nil + } + + // Resolve OIDC config from gateway if WithGateway was set. + if cfg.gateway != "" { + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(cfg.gateway) + if err != nil { + return nil, fmt.Errorf("failed to load gateway %q: %w", cfg.gateway, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return nil, fmt.Errorf( + "%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", + ErrOIDCConfig, cfg.gateway, + ) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + } + + // Validate required configuration. + if cfg.issuer == "" || cfg.clientID == "" { + return nil, fmt.Errorf( + "%w: issuer and client ID are required (use WithIssuer and WithClientID, or WithGateway)", + ErrOIDCConfig, + ) + } + if cfg.clientSecret == "" { + return nil, fmt.Errorf( + "%w: client secret is required (use WithClientSecret)", + ErrClientCredentials, + ) + } + + // Discover provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Build the token request with client credentials grant type. + data := url.Values{ + "grant_type": {"client_credentials"}, + "client_id": {cfg.clientID}, + "client_secret": {cfg.clientSecret}, + } + if len(cfg.scopes) > 0 { + data.Set("scope", strings.Join(cfg.scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, provider.TokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create token request", ErrClientCredentials) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + // Use a no-redirect client for token requests that carry client_secret + // in the POST body. A 307/308 redirect would replay the body (including + // the secret) to the redirect target. + noRedirectClient := *oidcHTTPClient + noRedirectClient.CheckRedirect = func(*http.Request, []*http.Request) error { + return http.ErrUseLastResponse + } + resp, err := noRedirectClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: token request failed", ErrClientCredentials) + } + defer func() { _ = resp.Body.Close() }() + + const maxResponseBytes = 1 << 20 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("%w: failed to read token response", ErrClientCredentials) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, fmt.Errorf("%w: invalid token response JSON", ErrClientCredentials) + } + + if resp.StatusCode != http.StatusOK || tokResp.Error != "" { + // FR-014: Never include the client secret in error messages. + // Only include the provider's error code and description. + msg := "client credentials exchange failed" + if tokResp.Error != "" { + msg = fmt.Sprintf("provider error: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + } + return nil, fmt.Errorf("%w: %s", ErrClientCredentials, msg) + } + + if tokResp.AccessToken == "" { + return nil, fmt.Errorf("%w: token response missing access_token", ErrClientCredentials) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/credentials_test.go b/sdk/go/openshell/v1/oidc/credentials_test.go new file mode 100644 index 0000000000..f87c6b31fb --- /dev/null +++ b/sdk/go/openshell/v1/oidc/credentials_test.go @@ -0,0 +1,277 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T023: Client credentials tests --- + +// setupCredentialsMockProvider creates a mock OIDC provider for client +// credentials testing. The token endpoint validates Basic Auth and +// returns a token response. Returns the server and the expected +// client ID / client secret pair. +func setupCredentialsMockProvider(t *testing.T, expectedClientID, expectedSecret string) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // Validate grant type. + if r.Form.Get("grant_type") != "client_credentials" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"unsupported_grant_type","error_description":"expected client_credentials"}`)) + return + } + + // Check credentials from form body (client_id + client_secret) + // or Basic Auth header. + clientID := r.Form.Get("client_id") + clientSecret := r.Form.Get("client_secret") + if clientID == "" || clientSecret == "" { + // Try Basic Auth. + var ok bool + clientID, clientSecret, ok = r.BasicAuth() + if !ok { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"missing credentials"}`)) + return + } + } + + if clientID != expectedClientID || clientSecret != expectedSecret { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"invalid_client","error_description":"invalid credentials"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("cc-access-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestClientCredentials_Success verifies the happy path: valid client +// ID, secret, and issuer produce a valid access token. +func TestClientCredentials_Success(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "my-client", "my-secret") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.NoError(t, err) + assert.Equal(t, "cc-access-token", tok.AccessToken) + assert.Empty(t, tok.RefreshToken, "client credentials should not return a refresh token") +} + +// TestClientCredentials_MissingIssuer verifies that ClientCredentials +// returns ErrOIDCConfig when the issuer is not set. +func TestClientCredentials_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestClientCredentials_MissingClientID verifies that ClientCredentials +// returns ErrOIDCConfig when the client ID is not set. +func TestClientCredentials_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithIssuer("https://example.com"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestClientCredentials_MissingClientSecret verifies that +// ClientCredentials returns ErrClientCredentials when the secret is +// missing. +func TestClientCredentials_MissingClientSecret(t *testing.T) { + resetDiscoveryCache() + + _, err := ClientCredentials(context.Background(), + WithIssuer("https://example.com"), + WithClientID("my-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrClientCredentials), "expected ErrClientCredentials, got: %v", err) +} + +// TestClientCredentials_InvalidCredentials verifies that +// ClientCredentials returns ErrClientCredentials when the provider +// rejects the credentials, and that the secret is not leaked in the +// error message. +func TestClientCredentials_InvalidCredentials(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "good-client", "good-secret") + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("good-client"), + WithClientSecret("wrong-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrClientCredentials), "expected ErrClientCredentials, got: %v", err) + + // FR-014: The secret must NEVER appear in error messages. + assert.NotContains(t, err.Error(), "wrong-secret", "secret must not leak in error message") + assert.NotContains(t, err.Error(), "good-secret", "secret must not leak in error message") +} + +// TestClientCredentials_DiscoveryFailure verifies that +// ClientCredentials returns ErrDiscovery when the provider is +// unreachable. +func TestClientCredentials_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := ClientCredentials(ctx, + WithIssuer("http://127.0.0.1:1"), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestClientCredentials_WithGateway verifies that ClientCredentials +// resolves OIDC config from gateway metadata when WithGateway is set. +func TestClientCredentials_WithGateway(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "gw-client", "gw-secret") + + fakeConfig := &gateway.Config{ + Name: "cc-gateway", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + OIDCIssuer: provider.URL, + OIDCClientID: "gw-client", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithGateway("cc-gateway"), + WithClientSecret("gw-secret"), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "cc-gateway", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "cc-access-token", tok.AccessToken) +} + +// TestClientCredentials_CustomScopes verifies that WithScopes overrides +// default scopes in the client credentials request. +func TestClientCredentials_CustomScopes(t *testing.T) { + resetDiscoveryCache() + + var receivedScope string + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + receivedScope = r.Form.Get("scope") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("scoped-cc-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := ClientCredentials(ctx, + WithIssuer(srv.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + WithScopes("api:read", "api:write"), + ) + require.NoError(t, err) + assert.Equal(t, "scoped-cc-token", tok.AccessToken) + assert.Equal(t, "api:read api:write", receivedScope) +} + +// TestClientCredentials_ContextCancellation verifies that +// ClientCredentials respects context cancellation. +func TestClientCredentials_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + provider := setupCredentialsMockProvider(t, "my-client", "my-secret") + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := ClientCredentials(ctx, + WithIssuer(provider.URL), + WithClientID("my-client"), + WithClientSecret("my-secret"), + ) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/device.go b/sdk/go/openshell/v1/oidc/device.go new file mode 100644 index 0000000000..ed73654181 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/device.go @@ -0,0 +1,292 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// deviceAuthResponse holds the parsed response from the device +// authorization endpoint (RFC 8628 Section 3.2). +type deviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int64 `json:"expires_in"` + Interval int64 `json:"interval"` +} + +// DeviceLogin performs an OAuth2 device authorization grant (RFC 8628). +// +// The flow requests a device code and user code from the provider's +// device authorization endpoint, displays them to the user (via +// [WithDisplayFunc] or stdout), and polls the token endpoint until the +// user completes authorization. +// +// Required options: [WithIssuer] and [WithClientID], or [WithGateway]. +// +// The polling loop respects the provider's interval and handles the +// following token endpoint error codes: +// - "authorization_pending": continue polling at the current interval +// - "slow_down": increase the polling interval by 5 seconds (RFC 8628 Section 3.5) +// - "expired_token": the device code has expired, return [ErrDeviceCode] +// - any other error: return [ErrDeviceCode] +func DeviceLogin(ctx context.Context, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + if _, hasDeadline := ctx.Deadline(); !hasDeadline && cfg.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.timeout) + defer cancel() + } + + // Resolve OIDC config from gateway if WithGateway was set. + if cfg.gateway != "" { + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(cfg.gateway) + if err != nil { + return nil, fmt.Errorf("failed to load gateway %q: %w", cfg.gateway, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return nil, fmt.Errorf( + "%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", + ErrOIDCConfig, cfg.gateway, + ) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + } + + // Validate required configuration. + if cfg.issuer == "" || cfg.clientID == "" { + return nil, fmt.Errorf( + "%w: issuer and client ID are required (use WithIssuer and WithClientID, or WithGateway)", + ErrOIDCConfig, + ) + } + + // Discover provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Verify the provider supports device authorization. + if provider.DeviceAuthorizationEndpoint == "" { + return nil, fmt.Errorf( + "%w: provider does not support device authorization (no device_authorization_endpoint in discovery)", + ErrDeviceCode, + ) + } + + // Request a device code from the provider. + deviceResp, err := requestDeviceCode(ctx, provider.DeviceAuthorizationEndpoint, cfg.clientID, cfg.scopes) + if err != nil { + return nil, err + } + + // Display the verification URL and user code to the user. + if cfg.displayFunc != nil { + cfg.displayFunc(deviceResp.VerificationURI, deviceResp.UserCode) + } else { + fmt.Printf("To sign in, visit: %s\n", deviceResp.VerificationURI) + fmt.Printf("Enter code: %s\n", deviceResp.UserCode) + } + + // Enforce device code lifetime from the provider's expires_in field. + // If the caller's context already has a shorter deadline, that takes + // precedence. This prevents indefinite polling against non-compliant + // providers that never return expired_token. + if deviceResp.ExpiresIn > 0 { + expiry := time.Duration(deviceResp.ExpiresIn) * time.Second + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, expiry) + defer cancel() + } + + // Poll the token endpoint until authorization completes, expires, + // or the context is cancelled. + interval := deviceResp.Interval + if interval < 1 { + interval = 5 // default polling interval per RFC 8628 + } + + return pollDeviceToken(ctx, provider.TokenEndpoint, cfg.clientID, deviceResp.DeviceCode, interval) +} + +// requestDeviceCode sends a POST to the device authorization endpoint +// and returns the parsed response containing the device code, user +// code, and verification URI. +func requestDeviceCode(ctx context.Context, endpoint, clientID string, scopes []string) (*deviceAuthResponse, error) { + data := url.Values{ + "client_id": {clientID}, + } + if len(scopes) > 0 { + data.Set("scope", strings.Join(scopes, " ")) + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, fmt.Errorf("%w: failed to create device authorization request", ErrDeviceCode) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := oidcHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: device authorization request failed", ErrDeviceCode) + } + defer func() { _ = resp.Body.Close() }() + + const maxResponseBytes = 1 << 20 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("%w: failed to read device authorization response", ErrDeviceCode) + } + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: device authorization endpoint returned HTTP %d", ErrDeviceCode, resp.StatusCode) + } + + var deviceResp deviceAuthResponse + if err := json.Unmarshal(body, &deviceResp); err != nil { + return nil, fmt.Errorf("%w: invalid device authorization response JSON", ErrDeviceCode) + } + + if deviceResp.DeviceCode == "" || deviceResp.UserCode == "" { + return nil, fmt.Errorf("%w: device authorization response missing device_code or user_code", ErrDeviceCode) + } + + return &deviceResp, nil +} + +// pollDeviceToken polls the token endpoint at the given interval until +// the user completes authorization. It handles RFC 8628 error codes: +// - "authorization_pending": keep polling +// - "slow_down": increase interval by 5 seconds +// - "expired_token": return ErrDeviceCode +func pollDeviceToken(ctx context.Context, tokenEndpoint, clientID, deviceCode string, interval int64) (*oauth2.Token, error) { + ticker := time.NewTicker(time.Duration(interval) * time.Second) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case <-ticker.C: + tok, done, slowDown, err := tryDeviceTokenExchange(ctx, tokenEndpoint, clientID, deviceCode) + if done { + if err != nil { + return nil, err + } + return tok, nil + } + // Adjust interval if the provider requested slow_down + // (+5 seconds per RFC 8628 Section 3.5). + if slowDown < 0 { + interval += 5 + ticker.Reset(time.Duration(interval) * time.Second) + } + } + } +} + +// tryDeviceTokenExchange makes a single token request for the device +// code grant. Returns: +// - (token, true, 0, nil): success +// - (nil, true, 0, err): terminal error (expired, access_denied, etc.) +// - (nil, false, interval, nil): continue polling (authorization_pending or slow_down) +func tryDeviceTokenExchange(ctx context.Context, tokenEndpoint, clientID, deviceCode string) (*oauth2.Token, bool, int64, error) { + data := url.Values{ + "grant_type": {"urn:ietf:params:oauth:grant-type:device_code"}, + "device_code": {deviceCode}, + "client_id": {clientID}, + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenEndpoint, strings.NewReader(data.Encode())) + if err != nil { + return nil, true, 0, fmt.Errorf("%w: failed to create token request", ErrDeviceCode) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + + resp, err := oidcHTTPClient.Do(req) + if err != nil { + // If the context was cancelled or timed out, surface that as + // ErrTimeout so callers can distinguish "user/caller cancelled" + // from a genuine device-code error. + if ctx.Err() != nil { + return nil, true, 0, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + } + // Other network errors during polling are terminal. + return nil, true, 0, fmt.Errorf("%w: token request failed", ErrDeviceCode) + } + defer func() { _ = resp.Body.Close() }() + + const maxTokenResponseBytes = 1 << 20 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxTokenResponseBytes)) + if err != nil { + return nil, true, 0, fmt.Errorf("%w: failed to read token response", ErrDeviceCode) + } + + var tokResp tokenResponse + if err := json.Unmarshal(body, &tokResp); err != nil { + return nil, true, 0, fmt.Errorf("%w: invalid token response JSON", ErrDeviceCode) + } + + // Handle error responses per RFC 8628 Section 3.5. + if tokResp.Error != "" { + switch tokResp.Error { + case "authorization_pending": + // User has not yet completed authorization. Keep polling. + return nil, false, 0, nil + case "slow_down": + // Provider requests increased interval (+5 seconds per RFC 8628). + // Return a sentinel value; the caller adds 5 to current interval. + return nil, false, -1, nil + case "expired_token": + return nil, true, 0, fmt.Errorf("%w: device code expired", ErrDeviceCode) + case "access_denied": + return nil, true, 0, fmt.Errorf("%w: access denied by user", ErrDeviceCode) + default: + msg := fmt.Sprintf("device code exchange failed: %s", tokResp.Error) + if tokResp.ErrorDesc != "" { + msg += ": " + tokResp.ErrorDesc + } + return nil, true, 0, fmt.Errorf("%w: %s", ErrDeviceCode, msg) + } + } + + // Success: parse the token. + if resp.StatusCode != http.StatusOK { + return nil, true, 0, fmt.Errorf("%w: token endpoint returned HTTP %d", ErrDeviceCode, resp.StatusCode) + } + + tok := &oauth2.Token{ + AccessToken: tokResp.AccessToken, + RefreshToken: tokResp.RefreshToken, + TokenType: tokResp.TokenType, + } + if tokResp.ExpiresIn > 0 { + tok.Expiry = time.Now().Add(time.Duration(tokResp.ExpiresIn) * time.Second) + } + + return tok, true, 0, nil +} diff --git a/sdk/go/openshell/v1/oidc/device_test.go b/sdk/go/openshell/v1/oidc/device_test.go new file mode 100644 index 0000000000..7571de6b82 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/device_test.go @@ -0,0 +1,698 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T025: Device code flow tests --- + +// setupDeviceMockProvider creates a mock OIDC provider for device code +// flow testing. The device authorization endpoint returns a device code +// and verification URL. The token endpoint simulates polling behavior: +// it returns "authorization_pending" for the first N polls, then returns +// a valid token response. +func setupDeviceMockProvider(t *testing.T, pendingPolls int) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + var pollCount atomic.Int32 + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "device_authorization_endpoint": srv.URL + "/device", + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "test-device-code", + "user_code": "ABCD-1234", + "verification_uri": "https://example.com/activate", + "verification_uri_complete": "https://example.com/activate?user_code=ABCD-1234", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + + // Only handle device code grants here. + if r.Form.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"unsupported_grant_type"}`)) + return + } + + count := pollCount.Add(1) + if int(count) <= pendingPolls { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("device-access-token", "device-refresh-token", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestDeviceLogin_Success verifies the happy path: the device code flow +// requests a device code, displays it, polls until authorized, and +// returns a valid token. +func TestDeviceLogin_Success(t *testing.T) { + resetDiscoveryCache() + + // Provider returns "authorization_pending" for the first 2 polls, + // then returns a token on the 3rd poll. + provider := setupDeviceMockProvider(t, 2) + + var displayedURL, displayedCode string + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(verificationURL, userCode string) { + displayedURL = verificationURL + displayedCode = userCode + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + assert.Equal(t, "device-refresh-token", tok.RefreshToken) + + // Verify the display callback was invoked with correct values. + assert.Equal(t, "https://example.com/activate", displayedURL) + assert.Equal(t, "ABCD-1234", displayedCode) +} + +// TestDeviceLogin_MissingIssuer verifies that DeviceLogin returns +// ErrOIDCConfig when the issuer is not provided. +func TestDeviceLogin_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := DeviceLogin(context.Background(), + WithClientID("device-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestDeviceLogin_MissingClientID verifies that DeviceLogin returns +// ErrOIDCConfig when the client ID is not provided. +func TestDeviceLogin_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := DeviceLogin(context.Background(), + WithIssuer("https://example.com"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestDeviceLogin_DiscoveryFailure verifies that DeviceLogin returns +// ErrDiscovery when the OIDC provider is unreachable. +func TestDeviceLogin_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer("http://127.0.0.1:1"), + WithClientID("device-client"), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestDeviceLogin_SlowDown verifies that the polling loop respects the +// "slow_down" response by increasing the polling interval. +func TestDeviceLogin_SlowDown(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + var pollCount atomic.Int32 + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "slow-device-code", + "user_code": "SLOW-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + count := pollCount.Add(1) + w.Header().Set("Content-Type", "application/json") + + switch count { + case 1: + // First poll: slow_down + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"slow_down"}`)) + case 2: + // Second poll: still pending + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"authorization_pending"}`)) + default: + // Third poll: success + _, _ = w.Write([]byte(tokenResponseJSON("slow-token", "", 3600))) + } + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.NoError(t, err) + assert.Equal(t, "slow-token", tok.AccessToken) +} + +// TestDeviceLogin_ExpiredDeviceCode verifies that DeviceLogin returns +// ErrDeviceCode when the device code expires before authorization. +func TestDeviceLogin_ExpiredDeviceCode(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "expiring-device-code", + "user_code": "EXPR-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"expired_token","error_description":"device code expired"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_CustomDisplayFunc verifies that WithDisplayFunc is +// invoked with the verification URL and user code. +func TestDeviceLogin_CustomDisplayFunc(t *testing.T) { + resetDiscoveryCache() + + // Provider that immediately returns a token (0 pending polls). + provider := setupDeviceMockProvider(t, 0) + + var called bool + var capturedURL, capturedCode string + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(verificationURL, userCode string) { + called = true + capturedURL = verificationURL + capturedCode = userCode + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) + assert.True(t, called, "display function should have been called") + assert.Equal(t, "https://example.com/activate", capturedURL) + assert.Equal(t, "ABCD-1234", capturedCode) +} + +// TestDeviceLogin_WithGateway verifies that DeviceLogin resolves OIDC +// config from gateway metadata when WithGateway is set. +func TestDeviceLogin_WithGateway(t *testing.T) { + resetDiscoveryCache() + + provider := setupDeviceMockProvider(t, 0) + + fakeConfig := &gateway.Config{ + Name: "device-gw", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + OIDCIssuer: provider.URL, + OIDCClientID: "gw-device-client", + } + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + tok, err := DeviceLogin(ctx, + WithGateway("device-gw"), + WithDisplayFunc(func(_, _ string) {}), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "device-gw", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "device-access-token", tok.AccessToken) +} + +// TestDeviceLogin_ContextCancellation verifies that DeviceLogin +// respects context cancellation during polling. +func TestDeviceLogin_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + // Provider that always returns "authorization_pending" so + // the polling loop never succeeds on its own. + provider := setupDeviceMockProvider(t, 1000) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(provider.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + // Should be ErrTimeout or context.DeadlineExceeded wrapped. + assert.True(t, + errors.Is(err, ErrTimeout) || errors.Is(err, context.DeadlineExceeded), + "expected timeout error, got: %v", err, + ) +} + +func TestDeviceLogin_WithTimeoutBoundsPolling(t *testing.T) { + resetDiscoveryCache() + provider := setupDeviceMockProvider(t, 1000) + + started := time.Now() + _, err := DeviceLogin(context.Background(), + WithIssuer(provider.URL), + WithClientID("device-client"), + WithTimeout(100*time.Millisecond), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.ErrorIs(t, err, ErrTimeout) + assert.Less(t, time.Since(started), time.Second) +} + +// TestDeviceLogin_AccessDenied verifies that DeviceLogin returns +// ErrDeviceCode when the user denies authorization. +func TestDeviceLogin_AccessDenied(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "denied-device-code", + "user_code": "DENY-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"access_denied","error_description":"user denied"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) + assert.Contains(t, err.Error(), "access denied") +} + +// TestDeviceLogin_UnknownError verifies that DeviceLogin returns +// ErrDeviceCode with the error description for unknown error codes. +func TestDeviceLogin_UnknownError(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "unknown-err-code", + "user_code": "UNKN-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, _ = w.Write([]byte(`{"error":"server_error","error_description":"internal failure"}`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) + assert.Contains(t, err.Error(), "server_error") + assert.Contains(t, err.Error(), "internal failure") +} + +// TestDeviceLogin_DeviceEndpointHTTPError verifies that DeviceLogin +// returns ErrDeviceCode when the device authorization endpoint returns +// a non-200 HTTP status. +func TestDeviceLogin_DeviceEndpointHTTPError(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + _, _ = w.Write([]byte("internal server error")) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_DeviceEndpointInvalidJSON verifies that DeviceLogin +// returns ErrDeviceCode when the device endpoint returns invalid JSON. +func TestDeviceLogin_DeviceEndpointInvalidJSON(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_MissingUserCode verifies that DeviceLogin returns +// ErrDeviceCode when the device endpoint returns an empty user_code. +func TestDeviceLogin_MissingUserCode(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "code-but-no-user", + "user_code": "", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_TokenEndpointInvalidJSON verifies that DeviceLogin +// returns ErrDeviceCode when the token endpoint returns invalid JSON +// during polling. +func TestDeviceLogin_TokenEndpointInvalidJSON(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + "device_authorization_endpoint": srv.URL + "/device", + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/device", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + resp := map[string]any{ + "device_code": "json-err-code", + "user_code": "JSON-1234", + "verification_uri": "https://example.com/activate", + "expires_in": 300, + "interval": 1, + } + _ = json.NewEncoder(w).Encode(resp) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{invalid json`)) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} + +// TestDeviceLogin_NoDeviceEndpoint verifies that DeviceLogin returns +// ErrDeviceCode when the provider does not advertise a device +// authorization endpoint. +func TestDeviceLogin_NoDeviceEndpoint(t *testing.T) { + resetDiscoveryCache() + + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "token_endpoint": srv.URL + "/token", + "authorization_endpoint": srv.URL + "/authorize", + // No device_authorization_endpoint. + } + _ = json.NewEncoder(w).Encode(doc) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := DeviceLogin(ctx, + WithIssuer(srv.URL), + WithClientID("device-client"), + WithDisplayFunc(func(_, _ string) {}), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDeviceCode), "expected ErrDeviceCode, got: %v", err) +} diff --git a/sdk/go/openshell/v1/oidc/discovery.go b/sdk/go/openshell/v1/oidc/discovery.go new file mode 100644 index 0000000000..d872027e88 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/discovery.go @@ -0,0 +1,174 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "net/url" + "strings" + "sync" + "time" +) + +var oidcHTTPClient = &http.Client{Timeout: 30 * time.Second} + +// providerConfig holds parsed fields from an OIDC discovery document +// (.well-known/openid-configuration). +type providerConfig struct { + Issuer string `json:"issuer"` + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"` + ScopesSupported []string `json:"scopes_supported"` + CodeChallengeMethodsSupported []string `json:"code_challenge_methods_supported"` +} + +const discoveryCacheTTL = 10 * time.Minute + +type discoveryCacheEntry struct { + config *providerConfig + fetchedAt time.Time +} + +// discoveryCache stores successfully fetched provider configurations +// keyed by normalized issuer URL. Entries expire after discoveryCacheTTL +// so that endpoint rotations are picked up without a process restart. +// Errors are not cached so that transient failures do not permanently +// poison the cache. +var ( + discoveryCacheMu sync.Mutex + discoveryCache = make(map[string]*discoveryCacheEntry) +) + +// resetDiscoveryCache clears the in-memory discovery cache. This is +// only used by tests to avoid interference between test cases. +func resetDiscoveryCache() { + discoveryCacheMu.Lock() + defer discoveryCacheMu.Unlock() + discoveryCache = make(map[string]*discoveryCacheEntry) +} + +// normalizeIssuer strips a trailing slash from the issuer URL so that +// "https://auth.example.com" and "https://auth.example.com/" resolve +// to the same cache key. +func normalizeIssuer(issuer string) string { + return strings.TrimRight(issuer, "/") +} + +// discover fetches and caches the OIDC discovery document for the +// given issuer URL. Only successful results are cached; failed +// fetches are retried on the next call. +func discover(ctx context.Context, issuer string) (*providerConfig, error) { + key := normalizeIssuer(issuer) + now := time.Now() + + discoveryCacheMu.Lock() + if entry, ok := discoveryCache[key]; ok && now.Before(entry.fetchedAt.Add(discoveryCacheTTL)) { + discoveryCacheMu.Unlock() + return entry.config, nil + } + discoveryCacheMu.Unlock() + + cfg, err := fetchDiscovery(ctx, key) + if err != nil { + return nil, err + } + + discoveryCacheMu.Lock() + if entry, ok := discoveryCache[key]; ok && now.Before(entry.fetchedAt.Add(discoveryCacheTTL)) { + discoveryCacheMu.Unlock() + return entry.config, nil + } + discoveryCache[key] = &discoveryCacheEntry{config: cfg, fetchedAt: now} + discoveryCacheMu.Unlock() + + return cfg, nil +} + +// fetchDiscovery performs the actual HTTP GET to the OIDC discovery +// endpoint and parses the response. +func fetchDiscovery(ctx context.Context, issuer string) (*providerConfig, error) { + if err := validateSecureURL("issuer", issuer); err != nil { + return nil, err + } + url := issuer + "/.well-known/openid-configuration" + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscovery, err) + } + + resp, err := oidcHTTPClient.Do(req) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrDiscovery, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("%w: discovery endpoint returned HTTP %d", ErrDiscovery, resp.StatusCode) + } + + const maxResponseBytes = 1 << 20 + body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseBytes)) + if err != nil { + return nil, fmt.Errorf("%w: failed to read discovery response: %v", ErrDiscovery, err) + } + + var cfg providerConfig + if err := json.Unmarshal(body, &cfg); err != nil { + return nil, fmt.Errorf("%w: invalid discovery JSON: %v", ErrDiscovery, err) + } + if normalizeIssuer(cfg.Issuer) != normalizeIssuer(issuer) { + return nil, fmt.Errorf("%w: discovery issuer %q does not match configured issuer %q", ErrDiscovery, cfg.Issuer, issuer) + } + + if cfg.TokenEndpoint == "" { + return nil, fmt.Errorf("%w: discovery document missing token_endpoint", ErrDiscovery) + } + if cfg.AuthorizationEndpoint == "" { + return nil, fmt.Errorf("%w: discovery document missing authorization_endpoint", ErrDiscovery) + } + for name, endpoint := range map[string]string{ + "issuer": cfg.Issuer, + "authorization_endpoint": cfg.AuthorizationEndpoint, + "token_endpoint": cfg.TokenEndpoint, + "device_authorization_endpoint": cfg.DeviceAuthorizationEndpoint, + } { + if endpoint != "" { + if err := validateSecureURL(name, endpoint); err != nil { + return nil, err + } + } + } + + return &cfg, nil +} + +func validateSecureURL(name, raw string) error { + u, err := url.Parse(raw) + if err != nil || u.Host == "" { + return fmt.Errorf("%w: invalid %s URL", ErrDiscovery, name) + } + if u.User != nil || u.Fragment != "" { + return fmt.Errorf("%w: %s URL must not contain userinfo or a fragment", ErrDiscovery, name) + } + if u.Scheme == "https" { + return nil + } + host := u.Hostname() + if u.Scheme == "http" && (strings.EqualFold(host, "localhost") || isLoopbackIP(host)) { + return nil + } + return fmt.Errorf("%w: %s URL must use HTTPS (HTTP is allowed only for loopback hosts)", ErrDiscovery, name) +} + +func isLoopbackIP(host string) bool { + ip := net.ParseIP(host) + return ip != nil && ip.IsLoopback() +} diff --git a/sdk/go/openshell/v1/oidc/discovery_test.go b/sdk/go/openshell/v1/oidc/discovery_test.go new file mode 100644 index 0000000000..ede7218785 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/discovery_test.go @@ -0,0 +1,263 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// wellKnownJSON returns a valid OIDC discovery document JSON string +// with the given issuer URL as the base. +func wellKnownJSON(issuer string) string { + return `{ + "issuer": "` + issuer + `", + "authorization_endpoint": "` + issuer + `/authorize", + "token_endpoint": "` + issuer + `/token", + "device_authorization_endpoint": "` + issuer + `/device", + "scopes_supported": ["openid", "profile", "email"], + "code_challenge_methods_supported": ["S256"] + }` +} + +func TestDiscover_ValidDocument(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + // Clear cache to avoid interference from other tests. + resetDiscoveryCache() + + cfg, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + assert.Equal(t, srv.URL, cfg.Issuer) + assert.Equal(t, srv.URL+"/authorize", cfg.AuthorizationEndpoint) + assert.Equal(t, srv.URL+"/token", cfg.TokenEndpoint) + assert.Equal(t, srv.URL+"/device", cfg.DeviceAuthorizationEndpoint) + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.ScopesSupported) + assert.Equal(t, []string{"S256"}, cfg.CodeChallengeMethodsSupported) +} + +func TestDiscover_CachesResult(t *testing.T) { + callCount := 0 + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + callCount++ + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + resetDiscoveryCache() + + cfg1, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + cfg2, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + // Same pointer should be returned from cache. + assert.Same(t, cfg1, cfg2) + assert.Equal(t, 1, callCount, "discovery should be fetched only once") +} + +func TestDiscover_DifferentIssuersNotCached(t *testing.T) { + handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issuer := "http://" + r.Host + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(issuer))) + }) + + srv1 := httptest.NewServer(handler) + defer srv1.Close() + srv2 := httptest.NewServer(handler) + defer srv2.Close() + + resetDiscoveryCache() + + cfg1, err := discover(context.Background(), srv1.URL) + require.NoError(t, err) + + cfg2, err := discover(context.Background(), srv2.URL) + require.NoError(t, err) + + // Different issuers should yield different cached entries. + assert.NotSame(t, cfg1, cfg2) +} + +func TestDiscover_HTTPError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusInternalServerError) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_InvalidJSON(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{not valid json}`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_MissingTokenEndpoint(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issuer := "http://" + r.Host + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issuer":"` + issuer + `","authorization_endpoint":"` + issuer + `/authorize"}`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) + assert.Contains(t, err.Error(), "token_endpoint") +} + +func TestDiscover_ContextCancelled(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(srv.URL))) + })) + defer srv.Close() + + resetDiscoveryCache() + + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + _, err := discover(ctx, srv.URL) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery)) +} + +func TestDiscover_ConcurrentAccess(t *testing.T) { + callCount := 0 + var mu sync.Mutex + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + callCount++ + mu.Unlock() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON("http://" + r.Host))) + })) + defer srv.Close() + + resetDiscoveryCache() + + var wg sync.WaitGroup + results := make([]*providerConfig, 10) + errs := make([]error, 10) + + for i := range 10 { + wg.Add(1) + go func(idx int) { + defer wg.Done() + results[idx], errs[idx] = discover(context.Background(), srv.URL) + }(i) + } + wg.Wait() + + for i := range 10 { + require.NoError(t, errs[i]) + assert.NotNil(t, results[i]) + } + + // Without sync.Once, concurrent goroutines may each fetch before + // the first result is cached. All calls should succeed, and the + // server should be called at most once per concurrent racer (not + // 10 times if caching works at all). In practice, most calls + // should hit the cache after the first fetch completes. + mu.Lock() + defer mu.Unlock() + assert.LessOrEqual(t, callCount, 10, "caching should reduce total fetches") +} + +func TestDiscover_NoDeviceEndpointIsOK(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + issuer := "http://" + r.Host + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"issuer":"` + issuer + `","authorization_endpoint":"` + issuer + `/authorize","token_endpoint":"` + issuer + `/token"}`)) + })) + defer srv.Close() + + resetDiscoveryCache() + + cfg, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + assert.Empty(t, cfg.DeviceAuthorizationEndpoint) +} + +func TestDiscover_TrailingSlashNormalized(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + callCount++ + issuer := "http://" + r.Host + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(wellKnownJSON(issuer))) + })) + defer srv.Close() + + resetDiscoveryCache() + + // Call with trailing slash and without; should be same cache entry. + _, err := discover(context.Background(), srv.URL) + require.NoError(t, err) + + _, err = discover(context.Background(), srv.URL+"/") + require.NoError(t, err) + + assert.Equal(t, 1, callCount, "trailing slash should be normalized for caching") +} + +func TestDiscover_RejectsIssuerMismatch(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(wellKnownJSON("https://attacker.example"))) + })) + defer srv.Close() + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "issuer") +} + +func TestDiscover_RejectsInsecureRemoteEndpoint(t *testing.T) { + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"issuer":"` + srv.URL + `","authorization_endpoint":"http://example.com/authorize","token_endpoint":"` + srv.URL + `/token"}`)) + })) + defer srv.Close() + resetDiscoveryCache() + + _, err := discover(context.Background(), srv.URL) + require.Error(t, err) + assert.Contains(t, err.Error(), "authorization_endpoint") +} diff --git a/sdk/go/openshell/v1/oidc/doc.go b/sdk/go/openshell/v1/oidc/doc.go new file mode 100644 index 0000000000..2575a09127 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/doc.go @@ -0,0 +1,78 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Package oidc provides OIDC authentication flows for the OpenShell SDK. +// +// The package supports four authentication flows: +// +// - Authorization Code with PKCE (interactive browser-based login) +// - Keyboard flow (manual URL copy and code paste for headless environments) +// - Device Code flow (RFC 8628, for input-constrained devices) +// - Client Credentials grant (non-interactive service account authentication) +// +// # Gateway-Aware Login +// +// The primary use case is gateway-aware login, where OIDC provider +// configuration is read from a gateway's metadata.json file: +// +// token, err := oidc.Login(ctx, "my-gateway") +// if err != nil { +// log.Fatal(err) +// } +// +// After successful authentication, tokens are persisted to disk in the +// gateway directory as oidc_token.json, compatible with +// [gateway.NewClient] and the existing [gateway.diskTokenSource]. +// +// # Standalone Login +// +// For OIDC providers not tied to an OpenShell gateway, use explicit +// configuration: +// +// token, err := oidc.Login(ctx, "", +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-app"), +// oidc.WithInMemory(), +// ) +// +// # Device Code Flow +// +// For environments without a browser: +// +// token, err := oidc.DeviceLogin(ctx, +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-app"), +// ) +// +// # Client Credentials +// +// For non-interactive service accounts: +// +// token, err := oidc.ClientCredentials(ctx, +// oidc.WithIssuer("https://auth.example.com"), +// oidc.WithClientID("my-service"), +// oidc.WithClientSecret("secret"), +// ) +// +// # Error Handling +// +// The package provides typed sentinel errors for precise failure +// classification: +// +// - [ErrDiscovery]: OIDC discovery fetch or parse failed +// - [ErrAuthCode]: Authorization code exchange failed +// - [ErrDeviceCode]: Device code flow failed +// - [ErrClientCredentials]: Client credentials exchange failed +// - [ErrTimeout]: Interactive flow timed out +// - [ErrCallbackServer]: Localhost callback server failed to start +// - [ErrTokenPersist]: Token disk write failed +// - [ErrOIDCConfig]: Gateway metadata missing OIDC fields +// +// All errors support [errors.Is] for classification. +// +// # Thread Safety +// +// All exported functions are safe for concurrent use from multiple +// goroutines. OIDC discovery documents are cached in memory per issuer +// URL for the lifetime of the process. +package oidc diff --git a/sdk/go/openshell/v1/oidc/errors.go b/sdk/go/openshell/v1/oidc/errors.go new file mode 100644 index 0000000000..20c9105c03 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/errors.go @@ -0,0 +1,43 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import "errors" + +// Sentinel errors for OIDC authentication failures. All wrapped errors +// returned by this package support classification via [errors.Is]. +var ( + // ErrDiscovery is returned when the OIDC discovery document + // (.well-known/openid-configuration) cannot be fetched or parsed. + ErrDiscovery = errors.New("oidc: discovery failed") + + // ErrAuthCode is returned when the authorization code exchange + // fails (invalid code, expired code, provider error). + ErrAuthCode = errors.New("oidc: auth code exchange failed") + + // ErrDeviceCode is returned when the device code flow fails + // (request error, expired device code, provider error). + ErrDeviceCode = errors.New("oidc: device code flow failed") + + // ErrClientCredentials is returned when the client credentials + // grant fails (invalid credentials, provider error). The error + // message never contains the client secret. + ErrClientCredentials = errors.New("oidc: client credentials exchange failed") + + // ErrTimeout is returned when an interactive login flow + // (browser, keyboard, or device code) exceeds its deadline. + ErrTimeout = errors.New("oidc: login timed out") + + // ErrCallbackServer is returned when the localhost HTTP server + // for the authorization code redirect cannot bind to any port. + ErrCallbackServer = errors.New("oidc: callback server failed") + + // ErrTokenPersist is returned when the token cannot be written + // to disk (permission error, invalid path). + ErrTokenPersist = errors.New("oidc: token persistence failed") + + // ErrOIDCConfig is returned when gateway metadata is missing + // the required oidc_issuer or oidc_client_id fields. + ErrOIDCConfig = errors.New("oidc: gateway OIDC config missing") +) diff --git a/sdk/go/openshell/v1/oidc/errors_test.go b/sdk/go/openshell/v1/oidc/errors_test.go new file mode 100644 index 0000000000..ea9dfb5a10 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/errors_test.go @@ -0,0 +1,107 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSentinelErrors_AreDistinct(t *testing.T) { + sentinels := []error{ + ErrDiscovery, + ErrAuthCode, + ErrDeviceCode, + ErrClientCredentials, + ErrTimeout, + ErrCallbackServer, + ErrTokenPersist, + ErrOIDCConfig, + } + + for i, a := range sentinels { + for j, b := range sentinels { + if i == j { + continue + } + assert.False(t, errors.Is(a, b), + "expected %v and %v to be distinct", a, b) + } + } +} + +func TestSentinelErrors_MatchSelf(t *testing.T) { + sentinels := []error{ + ErrDiscovery, + ErrAuthCode, + ErrDeviceCode, + ErrClientCredentials, + ErrTimeout, + ErrCallbackServer, + ErrTokenPersist, + ErrOIDCConfig, + } + + for _, sentinel := range sentinels { + assert.True(t, errors.Is(sentinel, sentinel), + "expected %v to match itself", sentinel) + } +} + +func TestSentinelErrors_WrappedMatchViIs(t *testing.T) { + cases := []struct { + name string + sentinel error + }{ + {"ErrDiscovery", ErrDiscovery}, + {"ErrAuthCode", ErrAuthCode}, + {"ErrDeviceCode", ErrDeviceCode}, + {"ErrClientCredentials", ErrClientCredentials}, + {"ErrTimeout", ErrTimeout}, + {"ErrCallbackServer", ErrCallbackServer}, + {"ErrTokenPersist", ErrTokenPersist}, + {"ErrOIDCConfig", ErrOIDCConfig}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + wrapped := fmt.Errorf("operation failed: %w", tc.sentinel) + assert.True(t, errors.Is(wrapped, tc.sentinel), + "wrapped error should match sentinel via errors.Is") + }) + } +} + +func TestSentinelErrors_HaveDescriptiveMessages(t *testing.T) { + cases := []struct { + sentinel error + contains string + }{ + {ErrDiscovery, "discovery"}, + {ErrAuthCode, "auth code"}, + {ErrDeviceCode, "device code"}, + {ErrClientCredentials, "client credentials"}, + {ErrTimeout, "timed out"}, + {ErrCallbackServer, "callback server"}, + {ErrTokenPersist, "token persistence"}, + {ErrOIDCConfig, "OIDC config"}, + } + + for _, tc := range cases { + t.Run(tc.sentinel.Error(), func(t *testing.T) { + assert.Contains(t, tc.sentinel.Error(), tc.contains) + }) + } +} + +func TestSentinelErrors_DoubleWrapped(t *testing.T) { + inner := fmt.Errorf("http timeout: %w", ErrDiscovery) + outer := fmt.Errorf("login failed: %w", inner) + + assert.True(t, errors.Is(outer, ErrDiscovery), + "double-wrapped error should still match sentinel") +} diff --git a/sdk/go/openshell/v1/oidc/example_test.go b/sdk/go/openshell/v1/oidc/example_test.go new file mode 100644 index 0000000000..c304ef0bfa --- /dev/null +++ b/sdk/go/openshell/v1/oidc/example_test.go @@ -0,0 +1,160 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// These examples demonstrate OIDC package usage but are guarded from +// execution during `go test` because they require real network access +// and user interaction. The guard `if false` keeps the code type-checked +// by the compiler without executing during tests. + +package oidc_test + +import ( + "context" + "fmt" + "log" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/oidc" +) + +func ExampleLogin_gateway() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "my-gateway") + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Authenticated. Token expires at %s\n", token.Expiry.Format(time.RFC3339)) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleLogin_standalone() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "", + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-app"), + oidc.WithInMemory(), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Access token: %s...\n", token.AccessToken[:10]) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleLogin_keyboard() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.Login(ctx, "my-gateway", + oidc.WithKeyboardFlow(), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Authenticated via keyboard flow. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleDeviceLogin() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-device-app"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Device authorized. Token expires at %s\n", token.Expiry.Format(time.RFC3339)) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleDeviceLogin_customDisplay() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + token, err := oidc.DeviceLogin(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-tui-app"), + oidc.WithDisplayFunc(func(verificationURL, userCode string) { + fmt.Printf("Please visit: %s\n", verificationURL) + fmt.Printf("Enter code: %s\n", userCode) + }), + ) + if err != nil { + log.Fatal(err) + } + + _ = token + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleClientCredentials() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + token, err := oidc.ClientCredentials(ctx, + oidc.WithIssuer("https://auth.example.com"), + oidc.WithClientID("my-service"), + oidc.WithClientSecret("service-secret"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Service authenticated. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} + +func ExampleClientCredentials_gateway() { + if false { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + token, err := oidc.ClientCredentials(ctx, + oidc.WithGateway("my-gateway"), + oidc.WithClientSecret("service-secret"), + ) + if err != nil { + log.Fatal(err) + } + + fmt.Printf("Service authenticated via gateway. Token type: %s\n", token.TokenType) + } + + fmt.Println("ok") + // Output: ok +} diff --git a/sdk/go/openshell/v1/oidc/keyboard.go b/sdk/go/openshell/v1/oidc/keyboard.go new file mode 100644 index 0000000000..3000a7560d --- /dev/null +++ b/sdk/go/openshell/v1/oidc/keyboard.go @@ -0,0 +1,97 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bufio" + "context" + "fmt" + "io" + "strings" +) + +type readResult struct { + code string + err error +} + +// keyboardFlow implements the keyboard fallback for the authorization +// code flow. It displays the authorization URL to the user and reads +// the pasted authorization code from the provided reader. +// +// Parameters: +// - ctx: context for cancellation/timeout +// - authURL: the full authorization URL to display +// - input: reader for user input (typically os.Stdin) +// - output: writer for prompts/instructions (typically os.Stderr) +// +// Returns the authorization code or an error. +func keyboardFlow(ctx context.Context, authURL string, input io.Reader, output io.Writer) (string, error) { + // Display instructions and URL. + _, _ = fmt.Fprintf(output, "\nOpen the following URL in your browser to authenticate:\n\n %s\n\n", authURL) + _, _ = fmt.Fprint(output, "Paste the authorization code here and press Enter: ") + + // Read code with context cancellation support. + result, err := keyboardInput.read(ctx, input) + if err != nil { + return "", err + } + if result.err != nil { + return "", result.err + } + if result.code == "" { + return "", fmt.Errorf("%w: empty authorization code", ErrAuthCode) + } + return result.code, nil +} + +type inputRequest struct { + input io.Reader + result chan readResult +} + +type inputDispatcher struct { + requests chan inputRequest +} + +var keyboardInput = newInputDispatcher() + +func newInputDispatcher() *inputDispatcher { + d := &inputDispatcher{requests: make(chan inputRequest)} + go d.run() + return d +} + +func (d *inputDispatcher) read(ctx context.Context, input io.Reader) (readResult, error) { + if err := ctx.Err(); err != nil { + return readResult{}, fmt.Errorf("%w: %v", ErrTimeout, err) + } + request := inputRequest{input: input, result: make(chan readResult, 1)} + select { + case d.requests <- request: + case <-ctx.Done(): + return readResult{}, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + } + select { + case result := <-request.result: + return result, nil + case <-ctx.Done(): + return readResult{}, fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + } +} + +func (d *inputDispatcher) run() { + for request := range d.requests { + scanner := bufio.NewScanner(request.input) + if scanner.Scan() { + request.result <- readResult{code: strings.TrimSpace(scanner.Text())} + continue + } + if err := scanner.Err(); err != nil { + request.result <- readResult{err: fmt.Errorf("%w: failed to read authorization code: %v", ErrAuthCode, err)} + } else { + request.result <- readResult{err: fmt.Errorf("%w: no authorization code received (EOF)", ErrAuthCode)} + } + } +} diff --git a/sdk/go/openshell/v1/oidc/keyboard_test.go b/sdk/go/openshell/v1/oidc/keyboard_test.go new file mode 100644 index 0000000000..080c4888b9 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/keyboard_test.go @@ -0,0 +1,172 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "bytes" + "context" + "errors" + "io" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// T014: Keyboard fallback flow tests + +func TestKeyboardFlow_ReadsCode(t *testing.T) { + // Simulate user pasting a code via stdin. + input := strings.NewReader("my-auth-code\n") + output := &bytes.Buffer{} + + code, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize?client_id=test", + input, + output, + ) + require.NoError(t, err) + assert.Equal(t, "my-auth-code", code) + + // Verify that the URL was displayed to the user. + assert.Contains(t, output.String(), "https://auth.example.com/authorize?client_id=test") +} + +func TestInputDispatcher_BoundsBlockedReads(t *testing.T) { + d := newInputDispatcher() + reader := &countingBlockingReader{release: make(chan struct{})} + t.Cleanup(func() { close(reader.release) }) + + for range 5 { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Millisecond) + _, err := d.read(ctx, reader) + cancel() + require.Error(t, err) + } + assert.Equal(t, int32(1), reader.max.Load()) +} + +func TestKeyboardFlow_TrimsWhitespace(t *testing.T) { + input := strings.NewReader(" some-code-with-spaces \n") + output := &bytes.Buffer{} + + code, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.NoError(t, err) + assert.Equal(t, "some-code-with-spaces", code) +} + +func TestKeyboardFlow_EmptyInput(t *testing.T) { + input := strings.NewReader("\n") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestKeyboardFlow_EOFBeforeInput(t *testing.T) { + // Reader that returns EOF immediately (e.g., piped /dev/null). + input := strings.NewReader("") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize", + input, + output, + ) + require.Error(t, err) + // Should be ErrAuthCode since no code was received. + assert.True(t, errors.Is(err, ErrAuthCode)) +} + +func TestKeyboardFlow_ContextCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately. + + // Use a reader that blocks forever (until context cancel). + input := &blockingReader{} + output := &bytes.Buffer{} + + _, err := keyboardFlow(ctx, "https://auth.example.com/authorize", input, output) + require.Error(t, err) +} + +func TestKeyboardFlow_DisplaysInstructions(t *testing.T) { + input := strings.NewReader("test-code\n") + output := &bytes.Buffer{} + + _, err := keyboardFlow( + context.Background(), + "https://auth.example.com/authorize?response_type=code", + input, + output, + ) + require.NoError(t, err) + + displayed := output.String() + // Must show the URL and some instruction text. + assert.Contains(t, displayed, "https://auth.example.com/authorize?response_type=code") + // Should prompt user to paste the code. + lower := strings.ToLower(displayed) + assert.True(t, + strings.Contains(lower, "paste") || strings.Contains(lower, "code") || strings.Contains(lower, "enter"), + "output should instruct the user to paste or enter the code", + ) +} + +func TestKeyboardFlow_Timeout(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + // Reader that never returns data. + input := &blockingReader{} + output := &bytes.Buffer{} + + _, err := keyboardFlow(ctx, "https://example.com/auth", input, output) + require.Error(t, err) +} + +// blockingReader is an io.Reader that blocks until the context is cancelled. +// It is used to simulate a user who never types anything. +type blockingReader struct{} + +func (r *blockingReader) Read(_ []byte) (int, error) { + // Block for a long time to simulate waiting for input. + time.Sleep(100 * time.Millisecond) + return 0, io.EOF +} + +type countingBlockingReader struct { + active atomic.Int32 + max atomic.Int32 + release chan struct{} +} + +func (r *countingBlockingReader) Read(_ []byte) (int, error) { + active := r.active.Add(1) + for { + old := r.max.Load() + if active <= old || r.max.CompareAndSwap(old, active) { + break + } + } + <-r.release + r.active.Add(-1) + return 0, io.EOF +} diff --git a/sdk/go/openshell/v1/oidc/oidc.go b/sdk/go/openshell/v1/oidc/oidc.go new file mode 100644 index 0000000000..916e31b78c --- /dev/null +++ b/sdk/go/openshell/v1/oidc/oidc.go @@ -0,0 +1,228 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "fmt" + "io" + "os" + "slices" + + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// Login performs an interactive OIDC authorization code login. +// +// When gatewayName is non-empty, Login resolves OIDC configuration +// (issuer URL and client ID) from the gateway's metadata.json file and +// persists tokens to the gateway directory. +// +// When gatewayName is empty, the caller must provide [WithIssuer] and +// [WithClientID] options explicitly. +// +// Before starting an interactive flow, Login checks for an existing +// valid token on disk (FR-019). If a valid, non-expired token is found, +// it is returned immediately without user interaction. +// +// The flow attempts to open a browser for authorization. If the browser +// cannot be opened, or if [WithKeyboardFlow] is set, the keyboard +// fallback flow is used instead. +func Login(ctx context.Context, gatewayName string, opts ...LoginOption) (*oauth2.Token, error) { + cfg := &loginConfig{} + for _, opt := range opts { + opt(cfg) + } + cfg.applyDefaults() + + // Apply configured timeout if the caller's context has no deadline. + if _, hasDeadline := ctx.Deadline(); !hasDeadline && cfg.timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.timeout) + defer cancel() + } + + // Resolve OIDC configuration from gateway or explicit options. + tokenDir, err := resolveOIDCConfig(cfg, gatewayName) + if err != nil { + return nil, err + } + + // FR-019: Check for existing valid token on disk before starting + // an interactive flow. + if tokenDir != "" { + tok, readErr := readToken(tokenDir) + if readErr == nil && tok != nil && tok.Valid() { + return tok, nil + } + // If readErr is a non-NotExist error, we log and proceed. + // Stale/expired tokens or missing files are not errors; we + // simply proceed to the interactive flow. + } + + // Run OIDC discovery to get provider endpoints. + provider, err := discover(ctx, cfg.issuer) + if err != nil { + return nil, err + } + + // Generate PKCE verifier and challenge if the provider supports S256. + var codeVerifier, codeChallenge string + if supportsS256(provider) { + codeVerifier, err = generateCodeVerifier() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthCode, err) + } + codeChallenge = codeChallengeS256(codeVerifier) + } + + // Generate cryptographic state for CSRF protection. + state, err := generateState() + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrAuthCode, err) + } + + // Determine the authorization code acquisition method. + // The redirectURI must match exactly between the auth request and + // the token exchange (OIDC/OAuth2 requirement). + var code, redirectURI string + if cfg.keyboardFlow { + redirectURI = "urn:ietf:wg:oauth:2.0:oob" + code, err = loginKeyboard(ctx, cfg, provider, state, codeChallenge) + } else { + code, redirectURI, err = loginBrowser(ctx, cfg, provider, state, codeChallenge) + } + if err != nil { + return nil, err + } + + // Exchange the authorization code for tokens. + tok, err := exchangeCode(ctx, provider.TokenEndpoint, cfg.clientID, code, redirectURI, codeVerifier) + if err != nil { + return nil, err + } + + // Persist token to disk unless in-memory mode is requested. + if !cfg.inMemory && tokenDir != "" { + if writeErr := writeToken(tokenDir, tok); writeErr != nil { + return nil, writeErr + } + } + + return tok, nil +} + +// resolveOIDCConfig resolves the OIDC issuer and client ID either from +// the gateway metadata or from explicit options. Returns the token +// directory path (empty if in-memory or no directory available). +func resolveOIDCConfig(cfg *loginConfig, gatewayName string) (string, error) { + tokenDir := cfg.tokenDir + + if gatewayName != "" { + // Resolve from gateway. + resolver := cfg.gatewayResolver + if resolver == nil { + resolver = gateway.LoadConfig + } + gwCfg, err := resolver(gatewayName) + if err != nil { + return "", fmt.Errorf("failed to load gateway %q: %w", gatewayName, err) + } + if gwCfg.OIDCIssuer == "" || gwCfg.OIDCClientID == "" { + return "", fmt.Errorf("%w: gateway %q has no OIDC configuration (missing oidc_issuer or oidc_client_id in metadata.json)", ErrOIDCConfig, gatewayName) + } + cfg.issuer = gwCfg.OIDCIssuer + cfg.clientID = gwCfg.OIDCClientID + if tokenDir == "" { + tokenDir = gwCfg.Dir + } + } + + // Validate that we have the minimum required config. + if cfg.issuer == "" || cfg.clientID == "" { + return "", fmt.Errorf("%w: issuer and client ID are required (provide a gateway name or use WithIssuer and WithClientID)", ErrOIDCConfig) + } + + return tokenDir, nil +} + +// supportsS256 checks if the OIDC provider advertises S256 PKCE support. +func supportsS256(provider *providerConfig) bool { + return slices.Contains(provider.CodeChallengeMethodsSupported, "S256") +} + +// loginKeyboard performs the keyboard flow: builds the auth URL, shows +// it to the user, and reads the pasted authorization code. +func loginKeyboard(ctx context.Context, cfg *loginConfig, provider *providerConfig, state, challenge string) (string, error) { + redirectURI := "urn:ietf:wg:oauth:2.0:oob" + authURL := buildAuthURL(provider.AuthorizationEndpoint, cfg.clientID, redirectURI, state, challenge, cfg.scopes) + + input := cfg.input + if input == nil { + input = os.Stdin + } + var output io.Writer = os.Stderr + if cfg.output != nil { + output = cfg.output + } + + return keyboardFlow(ctx, authURL, input, output) +} + +// loginBrowser performs the browser-based flow: starts a callback server, +// opens the browser, and waits for the callback. Falls back to keyboard +// if the browser cannot be opened. +// +// Returns (code, redirectURI, error). The redirectURI must be passed to +// exchangeCode so that it exactly matches the URI used in the auth +// request. When the function falls back to keyboard flow, the returned +// redirectURI is the keyboard placeholder ("urn:ietf:wg:oauth:2.0:oob"). +func loginBrowser(ctx context.Context, cfg *loginConfig, provider *providerConfig, state, challenge string) (string, string, error) { + port := cfg.callbackPort + if port == 0 { + port = 8000 + } + + srv, resultCh, err := startCallbackServer(ctx, port, state) + if err != nil { + // Try fallback port if the primary port failed and no custom + // port was specified. + if cfg.callbackPort == 0 { + port = 18000 + srv, resultCh, err = startCallbackServer(ctx, port, state) + } + if err != nil { + // Cannot start callback server, fall back to keyboard. + code, kbErr := loginKeyboard(ctx, cfg, provider, state, challenge) + return code, "urn:ietf:wg:oauth:2.0:oob", kbErr + } + } + defer func() { + _ = srv.Close() + }() + + redirectURI := fmt.Sprintf("http://localhost:%d/callback", port) + authURL := buildAuthURL(provider.AuthorizationEndpoint, cfg.clientID, redirectURI, state, challenge, cfg.scopes) + + // Try to open the browser. + if browserErr := openBrowser(authURL); browserErr != nil { + // Browser failed, fall back to keyboard flow. + _ = srv.Close() + code, kbErr := loginKeyboard(ctx, cfg, provider, state, challenge) + return code, "urn:ietf:wg:oauth:2.0:oob", kbErr + } + + // Wait for the callback result or context cancellation. + select { + case <-ctx.Done(): + return "", "", fmt.Errorf("%w: %v", ErrTimeout, ctx.Err()) + case result := <-resultCh: + if result.err != nil { + return "", "", result.err + } + return result.code, redirectURI, nil + } +} diff --git a/sdk/go/openshell/v1/oidc/oidc_test.go b/sdk/go/openshell/v1/oidc/oidc_test.go new file mode 100644 index 0000000000..5e27ab9e9b --- /dev/null +++ b/sdk/go/openshell/v1/oidc/oidc_test.go @@ -0,0 +1,496 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// --- T021: Login entry point tests --- + +// setupMockProvider creates a mock OIDC provider that serves discovery, +// authorize, and token endpoints. The token endpoint returns a valid +// token response. Returns the server (auto-cleaned up) and its URL. +func setupMockProvider(t *testing.T) *httptest.Server { + t.Helper() + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "device_authorization_endpoint": srv.URL + "/device", + "scopes_supported": []string{"openid", "profile", "email"}, + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("login-access-token", "login-refresh-token", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + return srv +} + +// TestLogin_MissingOIDCConfig verifies that Login returns ErrOIDCConfig +// when called without a gateway name and without WithIssuer/WithClientID. +func TestLogin_MissingOIDCConfig(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "") + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_MissingIssuer verifies that Login returns ErrOIDCConfig +// when only WithClientID is provided (missing issuer). +func TestLogin_MissingIssuer(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "", WithClientID("test-client")) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_MissingClientID verifies that Login returns ErrOIDCConfig +// when only WithIssuer is provided (missing client ID). +func TestLogin_MissingClientID(t *testing.T) { + resetDiscoveryCache() + + _, err := Login(context.Background(), "", WithIssuer("https://example.com")) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_ReusesValidToken verifies FR-019: when a valid token exists +// on disk in the token directory, Login returns it without starting an +// interactive flow. +func TestLogin_ReusesValidToken(t *testing.T) { + resetDiscoveryCache() + + // Create a temp dir with a valid, non-expired token file. + tokenDir := t.TempDir() + existingToken := &oauth2.Token{ + AccessToken: "existing-access-token", + RefreshToken: "existing-refresh-token", + TokenType: "Bearer", + Expiry: time.Now().Add(1 * time.Hour), + } + err := writeToken(tokenDir, existingToken) + require.NoError(t, err) + + // Login with explicit issuer/clientID and WithTokenDir (internal) + // pointing to the directory with the existing token. No OIDC + // provider is needed because the existing token is returned. + tok, err := Login(context.Background(), "", + WithIssuer("https://issuer-should-not-be-called.example.com"), + WithClientID("test-client"), + withTokenDir(tokenDir), + ) + require.NoError(t, err) + assert.Equal(t, "existing-access-token", tok.AccessToken) + assert.Equal(t, "existing-refresh-token", tok.RefreshToken) +} + +// TestLogin_ExpiredTokenTriggersFlow verifies that an expired token on +// disk does not short-circuit: Login proceeds to the interactive flow. +// Since we use keyboard flow (no browser), we feed it a code and verify +// a new token is returned. +func TestLogin_ExpiredTokenTriggersFlow(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + // Write an expired token. + expiredToken := &oauth2.Token{ + AccessToken: "expired-token", + RefreshToken: "old-refresh", + TokenType: "Bearer", + Expiry: time.Now().Add(-1 * time.Hour), // expired + } + err := writeToken(tokenDir, expiredToken) + require.NoError(t, err) + + // Start a callback server ourselves to simulate the auth code callback. + // We'll use keyboard flow to avoid browser dependency. + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + // Use keyboard flow with a reader that provides a fake auth code. + // The mock provider's /token endpoint accepts any code. + codeReader := strings.NewReader("fake-auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + assert.Equal(t, "login-refresh-token", tok.RefreshToken) +} + +// TestLogin_KeyboardFlow verifies that Login completes using the +// keyboard flow when WithKeyboardFlow() is set. The test provides a +// mock OIDC provider and feeds an auth code through a reader. +func TestLogin_KeyboardFlow(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("keyboard-auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify token was persisted to disk. + diskTok, err := readToken(tokenDir) + require.NoError(t, err) + require.NotNil(t, diskTok) + assert.Equal(t, "login-access-token", diskTok.AccessToken) +} + +// TestLogin_InMemorySkipsPersistence verifies that WithInMemory() +// returns a token without writing to disk. +func TestLogin_InMemorySkipsPersistence(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("some-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(tokenDir), + WithKeyboardFlow(), + WithInMemory(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify NO token file on disk. + _, err = os.Stat(filepath.Join(tokenDir, oidcTokenFile)) + assert.True(t, os.IsNotExist(err), "token file should not exist in in-memory mode") +} + +// TestLogin_DiscoveryFailure verifies that Login returns ErrDiscovery +// when the OIDC provider is unreachable. +func TestLogin_DiscoveryFailure(t *testing.T) { + resetDiscoveryCache() + + // Point to a server that doesn't exist. + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + _, err := Login(ctx, "", + WithIssuer("http://127.0.0.1:1"), // port 1 should refuse connections + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrDiscovery), "expected ErrDiscovery, got: %v", err) +} + +// TestLogin_GatewayResolution verifies that Login resolves OIDC config +// from gateway metadata when a gateway name is provided. We use the +// withGatewayResolver option to inject a fake gateway loader. +func TestLogin_GatewayResolution(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + tokenDir := t.TempDir() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("gw-auth-code\n") + + fakeConfig := &gateway.Config{ + Name: "test-gateway", + Endpoint: "gateway.example.com:443", + Dir: tokenDir, + OIDCIssuer: provider.URL, + OIDCClientID: "gateway-client-id", + } + + tok, err := Login(ctx, "test-gateway", + WithKeyboardFlow(), + withInput(codeReader), + withGatewayResolver(func(name string) (*gateway.Config, error) { + assert.Equal(t, "test-gateway", name) + return fakeConfig, nil + }), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) + + // Verify token was persisted in the gateway dir. + diskTok, err := readToken(tokenDir) + require.NoError(t, err) + require.NotNil(t, diskTok) + assert.Equal(t, "login-access-token", diskTok.AccessToken) +} + +// TestLogin_GatewayMissingOIDCFields verifies that Login returns +// ErrOIDCConfig when the gateway config has empty OIDC fields. +func TestLogin_GatewayMissingOIDCFields(t *testing.T) { + resetDiscoveryCache() + + fakeConfig := &gateway.Config{ + Name: "no-oidc-gw", + Endpoint: "gateway.example.com:443", + Dir: t.TempDir(), + // OIDCIssuer and OIDCClientID are empty. + } + + _, err := Login(context.Background(), "no-oidc-gw", + withGatewayResolver(func(_ string) (*gateway.Config, error) { + return fakeConfig, nil + }), + ) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrOIDCConfig), "expected ErrOIDCConfig, got: %v", err) +} + +// TestLogin_GatewayResolutionError verifies that Login propagates +// errors from gateway resolution. +func TestLogin_GatewayResolutionError(t *testing.T) { + resetDiscoveryCache() + + gwErr := fmt.Errorf("gateway not found: no-such-gateway") + + _, err := Login(context.Background(), "no-such-gateway", + withGatewayResolver(func(_ string) (*gateway.Config, error) { + return nil, gwErr + }), + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "gateway not found") +} + +// TestLogin_NoPKCESupport verifies that Login proceeds without PKCE +// when the OIDC provider does not advertise S256 support. +func TestLogin_NoPKCESupport(t *testing.T) { + resetDiscoveryCache() + + // Create a provider that does NOT list S256 in supported methods. + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "scopes_supported": []string{"openid"}, + // No code_challenge_methods_supported field. + } + _ = json.NewEncoder(w).Encode(doc) + }) + + var receivedVerifier string + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + receivedVerifier = r.Form.Get("code_verifier") + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("no-pkce-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("some-code\n") + + tok, err := Login(ctx, "", + WithIssuer(srv.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + WithKeyboardFlow(), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "no-pkce-token", tok.AccessToken) + + // Verify no PKCE verifier was sent to the token endpoint. + assert.Empty(t, receivedVerifier, "code_verifier should not be sent when PKCE is not supported") +} + +// TestLogin_ContextCancellation verifies that Login respects context +// cancellation during the interactive flow. +func TestLogin_ContextCancellation(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Create a context that is already cancelled. + ctx, cancel := context.WithCancel(context.Background()) + cancel() // cancel immediately + + _, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) + // Should get a context error or timeout error. + assert.True(t, + errors.Is(err, ErrTimeout) || errors.Is(err, ErrDiscovery) || errors.Is(err, context.Canceled), + "expected timeout/discovery/cancelled error, got: %v", err, + ) +} + +// TestLogin_CustomScopes verifies that WithScopes overrides the +// default scopes sent in the authorization request. +func TestLogin_CustomScopes(t *testing.T) { + resetDiscoveryCache() + + // Provider that captures the auth URL scope parameter. + mux := http.NewServeMux() + var srv *httptest.Server + + mux.HandleFunc("/.well-known/openid-configuration", func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + doc := map[string]any{ + "issuer": srv.URL, + "authorization_endpoint": srv.URL + "/authorize", + "token_endpoint": srv.URL + "/token", + "code_challenge_methods_supported": []string{"S256"}, + } + _ = json.NewEncoder(w).Encode(doc) + }) + + mux.HandleFunc("/token", func(w http.ResponseWriter, r *http.Request) { + _ = r.ParseForm() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(tokenResponseJSON("scoped-token", "", 3600))) + }) + + srv = httptest.NewServer(mux) + t.Cleanup(srv.Close) + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("auth-code\n") + + tok, err := Login(ctx, "", + WithIssuer(srv.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + WithKeyboardFlow(), + WithScopes("openid", "custom-scope"), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "scoped-token", tok.AccessToken) +} + +// TestLoginBrowser_PortBusy_FallbackToKeyboard verifies that +// loginBrowser falls back to keyboard flow when the callback server +// port is already occupied and no custom port is set. +func TestLoginBrowser_PortBusy_FallbackToKeyboard(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Occupy port 8000 so startCallbackServer fails on the primary port. + // Then occupy port 18000 so the fallback port also fails. + // This forces loginBrowser into the keyboard fallback path. + ln1, err1 := net.Listen("tcp", "127.0.0.1:8000") + ln2, err2 := net.Listen("tcp", "127.0.0.1:18000") + if err1 != nil || err2 != nil { + if ln1 != nil { + _ = ln1.Close() + } + if ln2 != nil { + _ = ln2.Close() + } + t.Skip("Cannot bind test ports 8000 and 18000") + } + defer func() { _ = ln1.Close() }() + defer func() { _ = ln2.Close() }() + + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + codeReader := strings.NewReader("keyboard-fallback-code\n") + + tok, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + withTokenDir(t.TempDir()), + withInput(codeReader), + ) + require.NoError(t, err) + assert.Equal(t, "login-access-token", tok.AccessToken) +} + +// TestLogin_ContextTimeout verifies that a Login with a very short +// timeout returns a timeout-related error. +func TestLogin_ContextTimeout(t *testing.T) { + resetDiscoveryCache() + + provider := setupMockProvider(t) + + // Create a context that times out immediately. + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Nanosecond) + defer cancel() + time.Sleep(1 * time.Millisecond) // ensure timeout fires + + _, err := Login(ctx, "", + WithIssuer(provider.URL), + WithClientID("test-client"), + WithKeyboardFlow(), + ) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/oidc/options.go b/sdk/go/openshell/v1/oidc/options.go new file mode 100644 index 0000000000..00dcd1e741 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/options.go @@ -0,0 +1,170 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "io" + "time" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" +) + +// defaultScopes are the OIDC scopes requested when no custom scopes +// are specified via [WithScopes]. +var defaultScopes = []string{"openid", "profile", "email"} + +// defaultTimeout is the maximum duration for interactive login flows +// (browser, keyboard, device code) when no custom timeout is set. +const defaultTimeout = 2 * time.Minute + +// loginConfig holds the resolved configuration for a single login +// attempt. It is built by applying [LoginOption] functions to a +// zero-value struct and then filling in defaults. +type loginConfig struct { + issuer string + clientID string + clientSecret string + scopes []string + scopesSet bool + callbackPort int + timeout time.Duration + keyboardFlow bool + inMemory bool + displayFunc func(verificationURL, userCode string) + gateway string + + // Internal fields for testing. Not exposed via public API. + tokenDir string // override token directory + input io.Reader // override stdin for keyboard flow + output io.Writer // override stderr for keyboard flow + gatewayResolver func(name string) (*gateway.Config, error) // override gateway.LoadConfig +} + +// applyDefaults fills in default values for fields that were not set +// by any option function. +func (c *loginConfig) applyDefaults() { + if len(c.scopes) == 0 { + // Deep copy to avoid callers mutating the package-level slice. + c.scopes = make([]string, len(defaultScopes)) + copy(c.scopes, defaultScopes) + } + if c.timeout == 0 { + c.timeout = defaultTimeout + } +} + +// LoginOption configures a login attempt. Use the With* functions to +// create option values. +type LoginOption func(*loginConfig) + +// WithIssuer sets the OIDC issuer URL. Required for standalone flows +// (when no gateway name is provided to [Login]). +func WithIssuer(url string) LoginOption { + return func(c *loginConfig) { + c.issuer = url + } +} + +// WithClientID sets the OAuth2 client ID. Required for standalone +// flows (when no gateway name is provided to [Login]). +func WithClientID(id string) LoginOption { + return func(c *loginConfig) { + c.clientID = id + } +} + +// WithClientSecret sets the client secret for the client credentials +// grant. Required for [ClientCredentials]. +func WithClientSecret(secret string) LoginOption { + return func(c *loginConfig) { + c.clientSecret = secret + } +} + +// WithScopes overrides the default scopes (openid, profile, email). +// The provided scopes replace the defaults entirely. +func WithScopes(scopes ...string) LoginOption { + return func(c *loginConfig) { + c.scopes = make([]string, len(scopes)) + copy(c.scopes, scopes) + c.scopesSet = true + } +} + +// WithCallbackPort sets a fixed port for the localhost callback server. +// By default the server tries port 8000, then 18000. +func WithCallbackPort(port int) LoginOption { + return func(c *loginConfig) { + c.callbackPort = port + } +} + +// WithTimeout sets the maximum duration for interactive login flows. +// The default is 2 minutes. +func WithTimeout(d time.Duration) LoginOption { + return func(c *loginConfig) { + c.timeout = d + } +} + +// WithKeyboardFlow forces the keyboard flow (manual URL copy and code +// paste) instead of attempting to open a browser. +func WithKeyboardFlow() LoginOption { + return func(c *loginConfig) { + c.keyboardFlow = true + } +} + +// WithInMemory skips persisting the token to disk. The returned token +// is only available in memory for the lifetime of the process. +func WithInMemory() LoginOption { + return func(c *loginConfig) { + c.inMemory = true + } +} + +// WithDisplayFunc sets a custom display function for the device code +// flow. The function receives the verification URL and user code that +// the user must enter to authorize the device. If not set, the default +// behavior prints to stdout. +func WithDisplayFunc(fn func(verificationURL, userCode string)) LoginOption { + return func(c *loginConfig) { + c.displayFunc = fn + } +} + +// WithGateway sets the gateway name for [DeviceLogin] and +// [ClientCredentials]. When set, OIDC config is read from the +// gateway's metadata.json and tokens are persisted to the gateway +// directory. +func WithGateway(name string) LoginOption { + return func(c *loginConfig) { + c.gateway = name + } +} + +// --- Internal options for testing (unexported) --- + +// withTokenDir overrides the token directory for testing. +func withTokenDir(dir string) LoginOption { + return func(c *loginConfig) { + c.tokenDir = dir + } +} + +// withInput overrides the input reader for keyboard flow testing. +func withInput(r io.Reader) LoginOption { + return func(c *loginConfig) { + c.input = r + } +} + +// withGatewayResolver overrides the gateway.LoadConfig function for +// testing. This allows tests to inject a fake gateway resolver +// without filesystem setup. +func withGatewayResolver(fn func(name string) (*gateway.Config, error)) LoginOption { + return func(c *loginConfig) { + c.gatewayResolver = fn + } +} diff --git a/sdk/go/openshell/v1/oidc/options_test.go b/sdk/go/openshell/v1/oidc/options_test.go new file mode 100644 index 0000000000..28d17c06b4 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/options_test.go @@ -0,0 +1,155 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestLoginConfig_Defaults(t *testing.T) { + var cfg loginConfig + cfg.applyDefaults() + + assert.Equal(t, []string{"openid", "profile", "email"}, cfg.scopes) + assert.Equal(t, 2*time.Minute, cfg.timeout) + assert.Empty(t, cfg.issuer) + assert.Empty(t, cfg.clientID) + assert.Empty(t, cfg.clientSecret) + assert.Zero(t, cfg.callbackPort) + assert.False(t, cfg.keyboardFlow) + assert.False(t, cfg.inMemory) + assert.Nil(t, cfg.displayFunc) + assert.Empty(t, cfg.gateway) +} + +func TestWithIssuer(t *testing.T) { + var cfg loginConfig + WithIssuer("https://auth.example.com")(&cfg) + + assert.Equal(t, "https://auth.example.com", cfg.issuer) +} + +func TestWithClientID(t *testing.T) { + var cfg loginConfig + WithClientID("my-app")(&cfg) + + assert.Equal(t, "my-app", cfg.clientID) +} + +func TestWithClientSecret(t *testing.T) { + var cfg loginConfig + WithClientSecret("s3cret")(&cfg) + + assert.Equal(t, "s3cret", cfg.clientSecret) +} + +func TestWithScopes(t *testing.T) { + var cfg loginConfig + WithScopes("openid", "custom")(&cfg) + cfg.applyDefaults() + + // Custom scopes should not be overwritten by defaults. + assert.Equal(t, []string{"openid", "custom"}, cfg.scopes) +} + +func TestWithScopes_DeepCopy(t *testing.T) { + original := []string{"openid", "custom"} + var cfg loginConfig + WithScopes(original...)(&cfg) + + // Mutating the original slice should not affect the config. + original[0] = "mutated" + assert.Equal(t, "openid", cfg.scopes[0]) +} + +func TestWithCallbackPort(t *testing.T) { + var cfg loginConfig + WithCallbackPort(9090)(&cfg) + + assert.Equal(t, 9090, cfg.callbackPort) +} + +func TestWithTimeout(t *testing.T) { + var cfg loginConfig + WithTimeout(5 * time.Minute)(&cfg) + cfg.applyDefaults() + + // Custom timeout should not be overwritten by defaults. + assert.Equal(t, 5*time.Minute, cfg.timeout) +} + +func TestWithKeyboardFlow(t *testing.T) { + var cfg loginConfig + WithKeyboardFlow()(&cfg) + + assert.True(t, cfg.keyboardFlow) +} + +func TestWithInMemory(t *testing.T) { + var cfg loginConfig + WithInMemory()(&cfg) + + assert.True(t, cfg.inMemory) +} + +func TestWithDisplayFunc(t *testing.T) { + called := false + fn := func(_, _ string) { called = true } + + var cfg loginConfig + WithDisplayFunc(fn)(&cfg) + + assert.NotNil(t, cfg.displayFunc) + cfg.displayFunc("http://example.com", "ABCD-1234") + assert.True(t, called) +} + +func TestWithGateway(t *testing.T) { + var cfg loginConfig + WithGateway("prod-gw")(&cfg) + + assert.Equal(t, "prod-gw", cfg.gateway) +} + +func TestMultipleOptions(t *testing.T) { + opts := []LoginOption{ + WithIssuer("https://auth.example.com"), + WithClientID("app-id"), + WithScopes("openid"), + WithTimeout(30 * time.Second), + WithKeyboardFlow(), + } + + var cfg loginConfig + for _, opt := range opts { + opt(&cfg) + } + cfg.applyDefaults() + + assert.Equal(t, "https://auth.example.com", cfg.issuer) + assert.Equal(t, "app-id", cfg.clientID) + assert.Equal(t, []string{"openid"}, cfg.scopes) + assert.Equal(t, 30*time.Second, cfg.timeout) + assert.True(t, cfg.keyboardFlow) +} + +func TestDefaultScopes_NotMutatedByConfig(t *testing.T) { + // Verify the package-level defaultScopes slice is not shared. + var cfg loginConfig + cfg.applyDefaults() + cfg.scopes[0] = "mutated" + + assert.Equal(t, "openid", defaultScopes[0]) +} + +func TestLastOptionWins(t *testing.T) { + var cfg loginConfig + WithIssuer("first")(&cfg) + WithIssuer("second")(&cfg) + + assert.Equal(t, "second", cfg.issuer) +} diff --git a/sdk/go/openshell/v1/oidc/token.go b/sdk/go/openshell/v1/oidc/token.go new file mode 100644 index 0000000000..7ae368c6d3 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/token.go @@ -0,0 +1,138 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "time" + + "golang.org/x/oauth2" +) + +// oidcTokenFile is the filename for persisted OIDC tokens. This must +// match the constant in gateway/token.go for interop. +const oidcTokenFile = "oidc_token.json" + +// tokenExpiryLeeway is the grace period subtracted from the token +// expiry when checking validity. Tokens expiring within this window +// are treated as expired to avoid using a token that expires during +// an in-flight request. +const tokenExpiryLeeway = 10 * time.Second + +// oidcBundle is the on-disk JSON representation of an OIDC token. +// The format is shared with the Rust CLI and the gateway package's +// diskTokenSource for interop. +type oidcBundle struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + Expiry string `json:"expiry"` + ExpiresIn int64 `json:"expires_in"` +} + +// writeToken persists an oauth2.Token to disk as oidc_token.json in +// the given directory. The file is written with 0600 permissions +// (owner-only) to protect credentials. +func writeToken(dir string, tok *oauth2.Token) error { + bundle := oidcBundle{ + AccessToken: tok.AccessToken, + RefreshToken: tok.RefreshToken, + } + + if !tok.Expiry.IsZero() { + bundle.Expiry = tok.Expiry.UTC().Format(time.RFC3339) + remaining := time.Until(tok.Expiry) + if remaining > 0 { + bundle.ExpiresIn = int64(remaining.Seconds()) + } + } + + data, err := json.Marshal(bundle) + if err != nil { + return fmt.Errorf("%w: failed to marshal token: %v", ErrTokenPersist, err) + } + + path := filepath.Join(dir, oidcTokenFile) + tmp, err := os.CreateTemp(dir, ".oidc-token-*") + if err != nil { + return fmt.Errorf("%w: failed to create temporary token file: %v", ErrTokenPersist, err) + } + tmpPath := tmp.Name() + defer func() { _ = os.Remove(tmpPath) }() + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return fmt.Errorf("%w: failed to secure temporary token file: %v", ErrTokenPersist, err) + } + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return fmt.Errorf("%w: failed to write %s: %v", ErrTokenPersist, path, err) + } + if err := tmp.Sync(); err != nil { + _ = tmp.Close() + return fmt.Errorf("%w: failed to sync %s: %v", ErrTokenPersist, path, err) + } + if err := tmp.Close(); err != nil { + return fmt.Errorf("%w: failed to close %s: %v", ErrTokenPersist, path, err) + } + if err := os.Rename(tmpPath, path); err != nil { + return fmt.Errorf("%w: failed to replace %s: %v", ErrTokenPersist, path, err) + } + + return nil +} + +// readToken reads an existing oidc_token.json from the given +// directory. It returns: +// - (token, nil) if the file exists, is valid, and the token has not +// expired (with leeway) +// - (nil, nil) if the file does not exist, the token is expired, or +// the access token is empty (not an error, just no reusable token) +// - (nil, error) if the file exists but cannot be parsed +func readToken(dir string) (*oauth2.Token, error) { + path := filepath.Join(dir, oidcTokenFile) + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, fmt.Errorf("%w: cannot read %s: %v", ErrTokenPersist, path, err) + } + + var bundle oidcBundle + if err := json.Unmarshal(data, &bundle); err != nil { + return nil, fmt.Errorf("%w: invalid JSON in %s: %v", ErrTokenPersist, oidcTokenFile, err) + } + + if bundle.AccessToken == "" { + return nil, nil + } + + tok := &oauth2.Token{ + AccessToken: bundle.AccessToken, + RefreshToken: bundle.RefreshToken, + TokenType: "Bearer", + } + + // Parse expiry from the "expiry" field (RFC 3339). Without an + // explicit expiry, the token is treated as non-expiring (always + // valid); "expires_in" alone cannot reconstruct an absolute time + // without a write timestamp. + if bundle.Expiry != "" { + expiry, parseErr := time.Parse(time.RFC3339, bundle.Expiry) + if parseErr != nil { + return nil, fmt.Errorf("%w: invalid expiry format in %s: %v", ErrTokenPersist, oidcTokenFile, parseErr) + } + tok.Expiry = expiry + } + + // Check if the token has expired (with leeway). + if !tok.Expiry.IsZero() && time.Now().After(tok.Expiry.Add(-tokenExpiryLeeway)) { + return nil, nil // Expired; caller should re-authenticate. + } + + return tok, nil +} diff --git a/sdk/go/openshell/v1/oidc/token_test.go b/sdk/go/openshell/v1/oidc/token_test.go new file mode 100644 index 0000000000..4840c882a4 --- /dev/null +++ b/sdk/go/openshell/v1/oidc/token_test.go @@ -0,0 +1,194 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package oidc + +import ( + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "golang.org/x/oauth2" +) + +func TestWriteToken_Success(t *testing.T) { + dir := t.TempDir() + tok := &oauth2.Token{ + AccessToken: "access-123", + RefreshToken: "refresh-456", + Expiry: time.Date(2026, 7, 3, 12, 0, 0, 0, time.UTC), + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + // Verify the file was written. + data, err := os.ReadFile(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + assert.Contains(t, string(data), `"access_token":"access-123"`) + assert.Contains(t, string(data), `"refresh_token":"refresh-456"`) + assert.Contains(t, string(data), `"expiry":"2026-07-03T12:00:00Z"`) +} + +func TestWriteToken_ReplacesInsecureExistingFileWithOwnerOnlyFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, oidcTokenFile) + require.NoError(t, os.WriteFile(path, []byte("old"), 0o644)) + + require.NoError(t, writeToken(dir, &oauth2.Token{AccessToken: "secret"})) + + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} + +func TestWriteToken_ExpiresInCalculated(t *testing.T) { + dir := t.TempDir() + expiry := time.Now().Add(3600 * time.Second) + tok := &oauth2.Token{ + AccessToken: "access-123", + Expiry: expiry, + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + data, err := os.ReadFile(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + // expires_in should be roughly 3600 (within a few seconds). + assert.Contains(t, string(data), `"expires_in":`) +} + +func TestWriteToken_InvalidDirectory(t *testing.T) { + err := writeToken("/nonexistent/path/that/does/not/exist", &oauth2.Token{ + AccessToken: "test", + Expiry: time.Now().Add(time.Hour), + }) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenPersist)) +} + +func TestReadToken_Success(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "access-123", + "refresh_token": "refresh-456", + "expiry": "2099-07-03T12:00:00Z", + "expires_in": 3600 + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + require.NoError(t, err) + assert.Equal(t, "access-123", tok.AccessToken) + assert.Equal(t, "refresh-456", tok.RefreshToken) + assert.False(t, tok.Expiry.IsZero()) +} + +func TestReadToken_MissingFile(t *testing.T) { + dir := t.TempDir() + + tok, err := readToken(dir) + assert.Nil(t, tok) + assert.NoError(t, err, "missing file should return nil token, no error") +} + +func TestReadToken_InvalidJSON(t *testing.T) { + dir := t.TempDir() + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(`{invalid`), 0o600) + require.NoError(t, err) + + _, err = readToken(dir) + require.Error(t, err) + assert.True(t, errors.Is(err, ErrTokenPersist)) +} + +func TestReadToken_ExpiredToken(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "expired-access", + "expiry": "2020-01-01T00:00:00Z" + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + assert.Nil(t, tok, "expired token should return nil") + assert.NoError(t, err, "expired token is not an error, just nil") +} + +func TestReadToken_ValidWithExpiresInFallback(t *testing.T) { + dir := t.TempDir() + // No expiry field, only expires_in. Since we wrote it "now", + // a large expires_in should make the token valid. + content := `{ + "access_token": "access-via-expires-in", + "expires_in": 99999 + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + require.NoError(t, err) + // Token with only expires_in cannot reconstruct a valid Expiry + // without knowing when the file was written. readToken should + // treat it as potentially valid and return it. + assert.NotNil(t, tok) + assert.Equal(t, "access-via-expires-in", tok.AccessToken) +} + +func TestReadToken_EmptyAccessToken(t *testing.T) { + dir := t.TempDir() + content := `{ + "access_token": "", + "expiry": "2099-01-01T00:00:00Z" + }` + err := os.WriteFile(filepath.Join(dir, "oidc_token.json"), []byte(content), 0o600) + require.NoError(t, err) + + tok, err := readToken(dir) + assert.Nil(t, tok, "empty access token should return nil") + assert.NoError(t, err) +} + +func TestWriteAndReadToken_Roundtrip(t *testing.T) { + dir := t.TempDir() + original := &oauth2.Token{ + AccessToken: "roundtrip-access", + RefreshToken: "roundtrip-refresh", + Expiry: time.Now().Add(time.Hour).Truncate(time.Second), + } + + err := writeToken(dir, original) + require.NoError(t, err) + + loaded, err := readToken(dir) + require.NoError(t, err) + require.NotNil(t, loaded) + + assert.Equal(t, original.AccessToken, loaded.AccessToken) + assert.Equal(t, original.RefreshToken, loaded.RefreshToken) + // Expiry should be close (within a second due to serialization). + assert.WithinDuration(t, original.Expiry, loaded.Expiry, time.Second) +} + +func TestWriteToken_FilePermissions(t *testing.T) { + dir := t.TempDir() + tok := &oauth2.Token{ + AccessToken: "perm-check", + Expiry: time.Now().Add(time.Hour), + } + + err := writeToken(dir, tok) + require.NoError(t, err) + + info, err := os.Stat(filepath.Join(dir, "oidc_token.json")) + require.NoError(t, err) + // File should be owner-only readable (0600). + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm()) +} diff --git a/sdk/go/openshell/v1/options.go b/sdk/go/openshell/v1/options.go index cb165b23a4..caac82c96b 100644 --- a/sdk/go/openshell/v1/options.go +++ b/sdk/go/openshell/v1/options.go @@ -10,18 +10,9 @@ import ( // CreateOptions configures resource creation. type CreateOptions = types.CreateOptions -// GetOptions configures resource retrieval. -type GetOptions = types.GetOptions - // ListOptions configures resource listing with pagination and filtering. type ListOptions = types.ListOptions -// DeleteOptions configures resource deletion. -type DeleteOptions = types.DeleteOptions - -// UpdateOptions configures resource updates. -type UpdateOptions = types.UpdateOptions - // WatchOptions configures watch behavior. type WatchOptions = types.WatchOptions diff --git a/sdk/go/openshell/v1/policy.go b/sdk/go/openshell/v1/policy.go index b6e5070d98..d1ebdaa264 100644 --- a/sdk/go/openshell/v1/policy.go +++ b/sdk/go/openshell/v1/policy.go @@ -87,87 +87,25 @@ var WithLimit = types.WithLimit // WithOffset sets the pagination offset. var WithOffset = types.WithOffset +// WithListGlobal enables global policy mode on List. When true, the query +// retrieves gateway-global policy revisions instead of sandbox-scoped ones. +var WithListGlobal = types.WithListGlobal + +// WithStatusGlobal enables global policy mode on GetStatus. When true, the +// query retrieves gateway-global policy status instead of sandbox-scoped status. +var WithStatusGlobal = types.WithStatusGlobal + // PolicyInterface defines operations for managing sandbox policy drafts, // approvals, and revision history. type PolicyInterface interface { - // GetDraft retrieves the current draft policy for a sandbox, including - // all pending, approved, and rejected chunks. Use WithStatusFilter to - // return only chunks matching a specific status. - // - // Errors: NotFound if the sandbox does not exist; InvalidArgument if the - // sandbox name is empty; Unimplemented by the fake client. GetDraft(ctx context.Context, workspace, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error) - - // ApproveDraftChunk approves a single pending draft chunk, merging - // its proposed rule into the active policy. - // - // Errors: NotFound if the sandbox or chunk does not exist; - // InvalidArgument if the sandbox name or chunk ID is empty; - // Conflict if the chunk has already been approved or rejected; - // Unimplemented by the fake client. ApproveDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*ApproveResult, error) - - // RejectDraftChunk rejects a single pending draft chunk with an - // optional reason that is fed to future LLM analysis context. - // - // Errors: NotFound if the sandbox or chunk does not exist; - // InvalidArgument if the sandbox name or chunk ID is empty; - // Conflict if the chunk has already been approved or rejected; - // Unimplemented by the fake client. RejectDraftChunk(ctx context.Context, workspace, sandboxName, chunkID, reason string) error - - // ApproveAllDraftChunks approves all pending draft chunks at once. - // By default, security-flagged chunks are skipped. Use - // WithIncludeSecurityFlagged to include them. - // - // Errors: NotFound if the sandbox does not exist; InvalidArgument if - // the sandbox name is empty; Unimplemented by the fake client. ApproveAllDraftChunks(ctx context.Context, workspace, sandboxName string, opts ...ApproveAllOption) (*ApproveAllResult, error) - - // ClearDraftChunks removes all pending draft chunks for a sandbox. - // - // Errors: NotFound if the sandbox does not exist; InvalidArgument if - // the sandbox name is empty; Unimplemented by the fake client. ClearDraftChunks(ctx context.Context, workspace, sandboxName string) (*ClearResult, error) - - // GetDraftHistory returns the chronological decision history for a - // sandbox's draft policy (approvals, rejections, edits, undos, clears). - // - // Errors: NotFound if the sandbox does not exist; InvalidArgument if - // the sandbox name is empty; Unimplemented by the fake client. GetDraftHistory(ctx context.Context, workspace, sandboxName string) ([]DraftHistoryEntry, error) - - // GetStatus retrieves the policy status for a sandbox, including the - // queried revision and the active version. Use WithVersion to query a - // specific version instead of the latest. - // - // Errors: NotFound if the sandbox or requested version does not exist; - // InvalidArgument if the sandbox name is empty; - // Unimplemented by the fake client. GetStatus(ctx context.Context, workspace, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) - - // List returns policy revisions for a sandbox, ordered by version. - // Use WithLimit and WithOffset for pagination. - // - // Errors: NotFound if the sandbox does not exist; InvalidArgument if - // the sandbox name is empty; Unimplemented by the fake client. List(ctx context.Context, workspace string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) - - // EditDraftChunk replaces the proposed rule of a pending draft chunk - // with the given network policy rule. - // - // Errors: NotFound if the sandbox or chunk does not exist; - // InvalidArgument if the sandbox name, chunk ID, or proposed rule is - // empty/nil; Conflict if the chunk is not in a pending state; - // Unimplemented by the fake client. EditDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error - - // UndoDraftChunk reverses a previously approved chunk, removing its - // merged rule from the active policy. - // - // Errors: NotFound if the sandbox or chunk does not exist; - // InvalidArgument if the sandbox name or chunk ID is empty; - // Conflict if the chunk has not been approved; - // Unimplemented by the fake client. UndoDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*UndoResult, error) } diff --git a/sdk/go/openshell/v1/policy_client.go b/sdk/go/openshell/v1/policy_client.go new file mode 100644 index 0000000000..fceeb52a60 --- /dev/null +++ b/sdk/go/openshell/v1/policy_client.go @@ -0,0 +1,167 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "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" +) + +type policyClient struct { + client pb.OpenShellClient +} + +func newPolicyClient(conn grpc.ClientConnInterface) *policyClient { + return &policyClient{client: pb.NewOpenShellClient(conn)} +} + +func (p *policyClient) GetDraft(ctx context.Context, workspace, sandboxName string, opts ...GetDraftOption) (*DraftPolicy, error) { + cfg := types.ApplyGetDraftOptions(opts) + resp, err := p.client.GetDraftPolicy(ctx, &pb.GetDraftPolicyRequest{ + Name: sandboxName, + StatusFilter: cfg.StatusFilter(), + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.DraftPolicyFromProto(resp), nil +} + +func (p *policyClient) ApproveDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*ApproveResult, error) { + resp, err := p.client.ApproveDraftChunk(ctx, &pb.ApproveDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ApproveResultFromProto(resp), nil +} + +func (p *policyClient) RejectDraftChunk(ctx context.Context, workspace, sandboxName, chunkID, reason string) error { + _, err := p.client.RejectDraftChunk(ctx, &pb.RejectDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + Reason: reason, + Workspace: workspace, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *policyClient) ApproveAllDraftChunks(ctx context.Context, workspace, sandboxName string, opts ...ApproveAllOption) (*ApproveAllResult, error) { + cfg := types.ApplyApproveAllOptions(opts) + resp, err := p.client.ApproveAllDraftChunks(ctx, &pb.ApproveAllDraftChunksRequest{ + Name: sandboxName, + IncludeSecurityFlagged: cfg.IncludeSecurityFlagged(), + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ApproveAllResultFromProto(resp), nil +} + +func (p *policyClient) ClearDraftChunks(ctx context.Context, workspace, sandboxName string) (*ClearResult, error) { + resp, err := p.client.ClearDraftChunks(ctx, &pb.ClearDraftChunksRequest{ + Name: sandboxName, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ClearResultFromProto(resp), nil +} + +func (p *policyClient) GetDraftHistory(ctx context.Context, workspace, sandboxName string) ([]DraftHistoryEntry, error) { + resp, err := p.client.GetDraftHistory(ctx, &pb.GetDraftHistoryRequest{ + Name: sandboxName, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + entries := resp.GetEntries() + if len(entries) == 0 { + return nil, nil + } + result := make([]DraftHistoryEntry, 0, len(entries)) + for _, e := range entries { + if converted := converter.DraftHistoryEntryFromProto(e); converted != nil { + result = append(result, *converted) + } + } + return result, nil +} + +func (p *policyClient) GetStatus(ctx context.Context, workspace, sandboxName string, opts ...GetStatusOption) (*PolicyStatusResult, error) { + cfg := types.ApplyGetStatusOptions(opts) + resp, err := p.client.GetSandboxPolicyStatus(ctx, &pb.GetSandboxPolicyStatusRequest{ + Name: sandboxName, + Version: cfg.Version(), + Workspace: workspace, + Global: cfg.Global(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.PolicyStatusResultFromProto(resp), nil +} + +func (p *policyClient) List(ctx context.Context, workspace string, opts ...ListPolicyOption) ([]SandboxPolicyRevision, error) { + cfg := types.ApplyListPolicyOptions(opts) + resp, err := p.client.ListSandboxPolicies(ctx, &pb.ListSandboxPoliciesRequest{ + Workspace: workspace, + Limit: cfg.Limit(), + Offset: cfg.Offset(), + Global: cfg.Global(), + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + revisions := resp.GetRevisions() + if len(revisions) == 0 { + return nil, nil + } + result := make([]SandboxPolicyRevision, 0, len(revisions)) + for _, r := range revisions { + if converted := converter.SandboxPolicyRevisionFromProto(r); converted != nil { + result = append(result, *converted) + } + } + return result, nil +} + +func (p *policyClient) EditDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string, proposedRule *NetworkPolicyRule) error { + _, err := p.client.EditDraftChunk(ctx, &pb.EditDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + ProposedRule: converter.NetworkPolicyRuleToProto(proposedRule), + Workspace: workspace, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *policyClient) UndoDraftChunk(ctx context.Context, workspace, sandboxName, chunkID string) (*UndoResult, error) { + resp, err := p.client.UndoDraftChunk(ctx, &pb.UndoDraftChunkRequest{ + Name: sandboxName, + ChunkId: chunkID, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.UndoResultFromProto(resp), nil +} diff --git a/sdk/go/openshell/v1/policy_client_test.go b/sdk/go/openshell/v1/policy_client_test.go new file mode 100644 index 0000000000..a517cd2288 --- /dev/null +++ b/sdk/go/openshell/v1/policy_client_test.go @@ -0,0 +1,1027 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "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" +) + +// --- Mock server for Policy RPCs --- + +type mockPolicyServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + + // Canned responses. + getDraftResp *pb.GetDraftPolicyResponse + approveResp *pb.ApproveDraftChunkResponse + rejectResp *pb.RejectDraftChunkResponse + approveAllResp *pb.ApproveAllDraftChunksResponse + clearResp *pb.ClearDraftChunksResponse + historyResp *pb.GetDraftHistoryResponse + statusResp *pb.GetSandboxPolicyStatusResponse + listResp *pb.ListSandboxPoliciesResponse + editResp *pb.EditDraftChunkResponse + undoResp *pb.UndoDraftChunkResponse + + // Recorded requests. + lastGetDraftReq *pb.GetDraftPolicyRequest + lastApproveReq *pb.ApproveDraftChunkRequest + lastRejectReq *pb.RejectDraftChunkRequest + lastApproveAllReq *pb.ApproveAllDraftChunksRequest + lastClearReq *pb.ClearDraftChunksRequest + lastHistoryReq *pb.GetDraftHistoryRequest + lastStatusReq *pb.GetSandboxPolicyStatusRequest + lastListReq *pb.ListSandboxPoliciesRequest + lastEditReq *pb.EditDraftChunkRequest + lastUndoReq *pb.UndoDraftChunkRequest + + // Inject errors. + getDraftErr error + approveErr error + rejectErr error + approveAllErr error + clearErr error + historyErr error + statusErr error + listErr error + editErr error + undoErr error +} + +func newMockPolicyServer() *mockPolicyServer { + return &mockPolicyServer{} +} + +func (s *mockPolicyServer) GetDraftPolicy(_ context.Context, req *pb.GetDraftPolicyRequest) (*pb.GetDraftPolicyResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastGetDraftReq = req + if s.getDraftErr != nil { + return nil, s.getDraftErr + } + return s.getDraftResp, nil +} + +func (s *mockPolicyServer) ApproveDraftChunk(_ context.Context, req *pb.ApproveDraftChunkRequest) (*pb.ApproveDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastApproveReq = req + if s.approveErr != nil { + return nil, s.approveErr + } + return s.approveResp, nil +} + +func (s *mockPolicyServer) RejectDraftChunk(_ context.Context, req *pb.RejectDraftChunkRequest) (*pb.RejectDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastRejectReq = req + if s.rejectErr != nil { + return nil, s.rejectErr + } + return s.rejectResp, nil +} + +func (s *mockPolicyServer) ApproveAllDraftChunks(_ context.Context, req *pb.ApproveAllDraftChunksRequest) (*pb.ApproveAllDraftChunksResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastApproveAllReq = req + if s.approveAllErr != nil { + return nil, s.approveAllErr + } + return s.approveAllResp, nil +} + +func (s *mockPolicyServer) ClearDraftChunks(_ context.Context, req *pb.ClearDraftChunksRequest) (*pb.ClearDraftChunksResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastClearReq = req + if s.clearErr != nil { + return nil, s.clearErr + } + return s.clearResp, nil +} + +func (s *mockPolicyServer) GetDraftHistory(_ context.Context, req *pb.GetDraftHistoryRequest) (*pb.GetDraftHistoryResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastHistoryReq = req + if s.historyErr != nil { + return nil, s.historyErr + } + return s.historyResp, nil +} + +func (s *mockPolicyServer) GetSandboxPolicyStatus(_ context.Context, req *pb.GetSandboxPolicyStatusRequest) (*pb.GetSandboxPolicyStatusResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastStatusReq = req + if s.statusErr != nil { + return nil, s.statusErr + } + return s.statusResp, nil +} + +func (s *mockPolicyServer) ListSandboxPolicies(_ context.Context, req *pb.ListSandboxPoliciesRequest) (*pb.ListSandboxPoliciesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastListReq = req + if s.listErr != nil { + return nil, s.listErr + } + return s.listResp, nil +} + +func (s *mockPolicyServer) EditDraftChunk(_ context.Context, req *pb.EditDraftChunkRequest) (*pb.EditDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastEditReq = req + if s.editErr != nil { + return nil, s.editErr + } + return s.editResp, nil +} + +func (s *mockPolicyServer) UndoDraftChunk(_ context.Context, req *pb.UndoDraftChunkRequest) (*pb.UndoDraftChunkResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastUndoReq = req + if s.undoErr != nil { + return nil, s.undoErr + } + return s.undoResp, nil +} + +// --- Test setup --- + +func setupPolicyTest(t *testing.T, mock *mockPolicyServer) (*policyClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newPolicyClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// =========================================================================== +// Phase 2 (T020): GetDraft, ApproveDraftChunk, RejectDraftChunk +// =========================================================================== + +func TestPolicyGetDraft(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftResp = &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + { + Id: "chunk-1", + Status: "pending", + RuleName: "allow-dns", + Rationale: "DNS access needed", + Confidence: 0.95, + DenialSummaryIds: []string{"ds-1", "ds-2"}, + CreatedAtMs: 1700000000000, + Stage: "initial", + HitCount: 3, + Binary: "/usr/bin/curl", + ProposedRule: &sbv1.NetworkPolicyRule{ + Name: "allow-dns-rule", + }, + }, + { + Id: "chunk-2", + Status: "approved", + RuleName: "allow-https", + }, + }, + RollingSummary: "Two rules proposed", + DraftVersion: 5, + LastAnalyzedAtMs: 1700000001000, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "default", "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, draft) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastGetDraftReq.GetName()) + assert.Empty(t, mock.lastGetDraftReq.GetStatusFilter()) + mock.mu.Unlock() + + // Verify response mapping. + assert.Equal(t, "Two rules proposed", draft.RollingSummary) + assert.Equal(t, uint64(5), draft.DraftVersion) + assert.False(t, draft.LastAnalyzedAt.IsZero()) + + require.Len(t, draft.Chunks, 2) + + c1 := draft.Chunks[0] + assert.Equal(t, "chunk-1", c1.ID) + assert.Equal(t, "pending", c1.Status) + assert.Equal(t, "allow-dns", c1.RuleName) + assert.Equal(t, "DNS access needed", c1.Rationale) + assert.InDelta(t, float32(0.95), c1.Confidence, 0.001) + assert.Equal(t, []string{"ds-1", "ds-2"}, c1.DenialSummaryIDs) + assert.Equal(t, "initial", c1.Stage) + assert.Equal(t, int32(3), c1.HitCount) + assert.Equal(t, "/usr/bin/curl", c1.Binary) + require.NotNil(t, c1.ProposedRule) + assert.Equal(t, "allow-dns-rule", c1.ProposedRule.Name) + + c2 := draft.Chunks[1] + assert.Equal(t, "chunk-2", c2.ID) + assert.Equal(t, "approved", c2.Status) +} + +func TestPolicyGetDraft_WithStatusFilter(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftResp = &pb.GetDraftPolicyResponse{ + Chunks: []*pb.PolicyChunk{ + {Id: "chunk-1", Status: "pending"}, + }, + DraftVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "default", "sb1", types.WithStatusFilter("pending")) + + require.NoError(t, err) + require.NotNil(t, draft) + + // Verify status filter was forwarded. + mock.mu.Lock() + assert.Equal(t, "pending", mock.lastGetDraftReq.GetStatusFilter()) + mock.mu.Unlock() + + require.Len(t, draft.Chunks, 1) + assert.Equal(t, "pending", draft.Chunks[0].Status) +} + +func TestPolicyGetDraft_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.getDraftErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + draft, err := client.GetDraft(context.Background(), "default", "missing") + + assert.Nil(t, draft) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyApproveDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.approveResp = &pb.ApproveDraftChunkResponse{ + PolicyVersion: 7, + PolicyHash: "sha256:abc123", + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveDraftChunk(context.Background(), "default", "my-sandbox", "chunk-1") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastApproveReq.GetName()) + assert.Equal(t, "chunk-1", mock.lastApproveReq.GetChunkId()) + mock.mu.Unlock() + + // Verify response mapping. + assert.Equal(t, uint32(7), result.PolicyVersion) + assert.Equal(t, "sha256:abc123", result.PolicyHash) +} + +func TestPolicyApproveDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.approveErr = status.Errorf(codes.NotFound, "chunk not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveDraftChunk(context.Background(), "default", "sb1", "bad-chunk") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyRejectDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.rejectResp = &pb.RejectDraftChunkResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.RejectDraftChunk(context.Background(), "default", "my-sandbox", "chunk-2", "too broad") + + require.NoError(t, err) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastRejectReq.GetName()) + assert.Equal(t, "chunk-2", mock.lastRejectReq.GetChunkId()) + assert.Equal(t, "too broad", mock.lastRejectReq.GetReason()) + mock.mu.Unlock() +} + +func TestPolicyRejectDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.rejectErr = status.Errorf(codes.InvalidArgument, "invalid chunk") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.RejectDraftChunk(context.Background(), "default", "sb1", "bad", "reason") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// =========================================================================== +// Phase 3 (T022): ApproveAllDraftChunks, ClearDraftChunks, GetDraftHistory +// =========================================================================== + +func TestPolicyApproveAllDraftChunks(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllResp = &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 8, + PolicyHash: "sha256:bulk", + ChunksApproved: 5, + ChunksSkipped: 2, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "default", "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify default: security-flagged NOT included. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastApproveAllReq.GetName()) + assert.False(t, mock.lastApproveAllReq.GetIncludeSecurityFlagged()) + mock.mu.Unlock() + + assert.Equal(t, uint32(8), result.PolicyVersion) + assert.Equal(t, "sha256:bulk", result.PolicyHash) + assert.Equal(t, uint32(5), result.ChunksApproved) + assert.Equal(t, uint32(2), result.ChunksSkipped) +} + +func TestPolicyApproveAllDraftChunks_WithSecurityFlagged(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllResp = &pb.ApproveAllDraftChunksResponse{ + PolicyVersion: 9, + PolicyHash: "sha256:all", + ChunksApproved: 7, + ChunksSkipped: 0, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "default", "sb1", types.WithIncludeSecurityFlagged()) + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify security-flagged flag was sent. + mock.mu.Lock() + assert.True(t, mock.lastApproveAllReq.GetIncludeSecurityFlagged()) + mock.mu.Unlock() + + assert.Equal(t, uint32(7), result.ChunksApproved) + assert.Equal(t, uint32(0), result.ChunksSkipped) +} + +func TestPolicyApproveAllDraftChunks_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.approveAllErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ApproveAllDraftChunks(context.Background(), "default", "missing") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyClearDraftChunks(t *testing.T) { + mock := newMockPolicyServer() + mock.clearResp = &pb.ClearDraftChunksResponse{ + ChunksCleared: 4, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ClearDraftChunks(context.Background(), "default", "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastClearReq.GetName()) + mock.mu.Unlock() + + assert.Equal(t, uint32(4), result.ChunksCleared) +} + +func TestPolicyClearDraftChunks_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.clearErr = status.Errorf(codes.Internal, "internal error") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.ClearDraftChunks(context.Background(), "default", "sb1") + + assert.Nil(t, result) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +func TestPolicyGetDraftHistory(t *testing.T) { + mock := newMockPolicyServer() + mock.historyResp = &pb.GetDraftHistoryResponse{ + Entries: []*pb.DraftHistoryEntry{ + { + TimestampMs: 1700000000000, + EventType: "approved", + Description: "Chunk chunk-1 approved", + ChunkId: "chunk-1", + }, + { + TimestampMs: 1700000001000, + EventType: "rejected", + Description: "Chunk chunk-2 rejected: too broad", + ChunkId: "chunk-2", + }, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "default", "my-sandbox") + + require.NoError(t, err) + require.Len(t, entries, 2) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastHistoryReq.GetName()) + mock.mu.Unlock() + + assert.Equal(t, "approved", entries[0].EventType) + assert.Equal(t, "Chunk chunk-1 approved", entries[0].Description) + assert.Equal(t, "chunk-1", entries[0].ChunkID) + assert.False(t, entries[0].Timestamp.IsZero()) + + assert.Equal(t, "rejected", entries[1].EventType) + assert.Equal(t, "chunk-2", entries[1].ChunkID) +} + +func TestPolicyGetDraftHistory_Empty(t *testing.T) { + mock := newMockPolicyServer() + mock.historyResp = &pb.GetDraftHistoryResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "default", "sb1") + + require.NoError(t, err) + assert.Nil(t, entries) +} + +func TestPolicyGetDraftHistory_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.historyErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + entries, err := client.GetDraftHistory(context.Background(), "default", "missing") + + assert.Nil(t, entries) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// =========================================================================== +// Phase 4 (T024): GetStatus, List, EditDraftChunk, UndoDraftChunk +// =========================================================================== + +func TestPolicyGetStatus(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 3, + PolicyHash: "sha256:rev3", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + CreatedAtMs: 1700000000000, + LoadedAtMs: 1700000001000, + }, + ActiveVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "default", "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded (no version = latest). + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastStatusReq.GetName()) + assert.Equal(t, uint32(0), mock.lastStatusReq.GetVersion()) + mock.mu.Unlock() + + assert.Equal(t, uint32(3), result.ActiveVersion) + assert.Equal(t, uint32(3), result.Revision.Version) + assert.Equal(t, "sha256:rev3", result.Revision.PolicyHash) + assert.Equal(t, PolicyLoadStatusLoaded, result.Revision.Status) + assert.False(t, result.Revision.CreatedAt.IsZero()) + assert.False(t, result.Revision.LoadedAt.IsZero()) +} + +func TestPolicyGetStatus_WithVersion(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 2, + PolicyHash: "sha256:rev2", + Status: pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, + }, + ActiveVersion: 3, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "default", "sb1", types.WithVersion(2)) + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify version was forwarded. + mock.mu.Lock() + assert.Equal(t, uint32(2), mock.lastStatusReq.GetVersion()) + mock.mu.Unlock() + + assert.Equal(t, uint32(2), result.Revision.Version) + assert.Equal(t, PolicyLoadStatusSuperseded, result.Revision.Status) + assert.Equal(t, uint32(3), result.ActiveVersion) +} + +func TestPolicyGetStatus_WithGlobal(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 1, + PolicyHash: "sha256:global-rev1", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + }, + ActiveVersion: 1, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + // GetStatus with global flag and empty name/workspace. + result, err := client.GetStatus(context.Background(), "", "", types.WithStatusGlobal(true)) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(1), result.Revision.Version) + assert.Equal(t, "sha256:global-rev1", result.Revision.PolicyHash) + + // Verify global flag was forwarded in the proto request. + mock.mu.Lock() + assert.True(t, mock.lastStatusReq.GetGlobal()) + assert.Empty(t, mock.lastStatusReq.GetName()) + assert.Empty(t, mock.lastStatusReq.GetWorkspace()) + mock.mu.Unlock() +} + +func TestPolicyGetStatus_WithGlobalIgnoresNonEmptyName(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 1, + PolicyHash: "sha256:global-rev1", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + }, + ActiveVersion: 1, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "some-workspace", "some-sandbox", types.WithStatusGlobal(true)) + + require.NoError(t, err) + require.NotNil(t, result) + + mock.mu.Lock() + assert.True(t, mock.lastStatusReq.GetGlobal()) + assert.Equal(t, "some-sandbox", mock.lastStatusReq.GetName()) + assert.Equal(t, "some-workspace", mock.lastStatusReq.GetWorkspace()) + mock.mu.Unlock() +} + +func TestPolicyGetStatus_WithGlobalAndVersion(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 3, + PolicyHash: "sha256:global-rev3", + Status: pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, + }, + ActiveVersion: 5, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + // Global flag composes with WithVersion. + result, err := client.GetStatus(context.Background(), "", "", + types.WithStatusGlobal(true), + types.WithVersion(3), + ) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, uint32(3), result.Revision.Version) + assert.Equal(t, uint32(5), result.ActiveVersion) + + mock.mu.Lock() + assert.True(t, mock.lastStatusReq.GetGlobal()) + assert.Equal(t, uint32(3), mock.lastStatusReq.GetVersion()) + mock.mu.Unlock() +} + +func TestPolicyGetStatus_WithoutGlobal_PreservesExistingBehavior(t *testing.T) { + mock := newMockPolicyServer() + mock.statusResp = &pb.GetSandboxPolicyStatusResponse{ + Revision: &pb.SandboxPolicyRevision{ + Version: 1, + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + }, + ActiveVersion: 1, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + _, err := client.GetStatus(context.Background(), "default", "my-sandbox") + + require.NoError(t, err) + + // Verify global flag is false by default. + mock.mu.Lock() + assert.False(t, mock.lastStatusReq.GetGlobal()) + assert.Equal(t, "default", mock.lastStatusReq.GetWorkspace()) + assert.Equal(t, "my-sandbox", mock.lastStatusReq.GetName()) + mock.mu.Unlock() +} + +func TestPolicyGetStatus_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.statusErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.GetStatus(context.Background(), "default", "missing") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyList(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + { + Version: 1, + PolicyHash: "sha256:v1", + Status: pb.PolicyStatus_POLICY_STATUS_SUPERSEDED, + CreatedAtMs: 1700000000000, + }, + { + Version: 2, + PolicyHash: "sha256:v2", + Status: pb.PolicyStatus_POLICY_STATUS_LOADED, + CreatedAtMs: 1700000001000, + LoadedAtMs: 1700000002000, + }, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "default") + + require.NoError(t, err) + require.Len(t, revisions, 2) + + // Verify request was forwarded (no pagination options). + mock.mu.Lock() + assert.Equal(t, "default", mock.lastListReq.GetWorkspace()) + assert.Equal(t, uint32(0), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(0), mock.lastListReq.GetOffset()) + mock.mu.Unlock() + + assert.Equal(t, uint32(1), revisions[0].Version) + assert.Equal(t, "sha256:v1", revisions[0].PolicyHash) + assert.Equal(t, PolicyLoadStatusSuperseded, revisions[0].Status) + + assert.Equal(t, uint32(2), revisions[1].Version) + assert.Equal(t, "sha256:v2", revisions[1].PolicyHash) + assert.Equal(t, PolicyLoadStatusLoaded, revisions[1].Status) +} + +func TestPolicyList_WithPagination(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + {Version: 3, PolicyHash: "sha256:v3"}, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "default", + types.WithLimit(10), + types.WithOffset(20), + ) + + require.NoError(t, err) + require.Len(t, revisions, 1) + + // Verify pagination options were forwarded. + mock.mu.Lock() + assert.Equal(t, uint32(10), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(20), mock.lastListReq.GetOffset()) + mock.mu.Unlock() +} + +func TestPolicyList_Empty(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "default") + + require.NoError(t, err) + assert.Nil(t, revisions) +} + +func TestPolicyList_WithGlobal(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + {Version: 1, PolicyHash: "sha256:global-v1"}, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + // List with global flag and empty workspace. + revisions, err := client.List(context.Background(), "", types.WithListGlobal(true)) + + require.NoError(t, err) + require.Len(t, revisions, 1) + assert.Equal(t, uint32(1), revisions[0].Version) + assert.Equal(t, "sha256:global-v1", revisions[0].PolicyHash) + + // Verify global flag was forwarded in the proto request. + mock.mu.Lock() + assert.True(t, mock.lastListReq.GetGlobal()) + assert.Empty(t, mock.lastListReq.GetWorkspace()) + mock.mu.Unlock() +} + +func TestPolicyList_WithGlobalIgnoresWorkspace(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + {Version: 1, PolicyHash: "sha256:global-v1"}, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "some-workspace", types.WithListGlobal(true)) + + require.NoError(t, err) + require.Len(t, revisions, 1) + + mock.mu.Lock() + assert.True(t, mock.lastListReq.GetGlobal()) + assert.Equal(t, "some-workspace", mock.lastListReq.GetWorkspace()) + mock.mu.Unlock() +} + +func TestPolicyList_WithGlobalAndPagination(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + {Version: 5, PolicyHash: "sha256:global-v5"}, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + // Global flag composes with pagination options. + revisions, err := client.List(context.Background(), "", + types.WithListGlobal(true), + types.WithLimit(10), + types.WithOffset(20), + ) + + require.NoError(t, err) + require.Len(t, revisions, 1) + + mock.mu.Lock() + assert.True(t, mock.lastListReq.GetGlobal()) + assert.Equal(t, uint32(10), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(20), mock.lastListReq.GetOffset()) + mock.mu.Unlock() +} + +func TestPolicyList_WithoutGlobal_PreservesExistingBehavior(t *testing.T) { + mock := newMockPolicyServer() + mock.listResp = &pb.ListSandboxPoliciesResponse{ + Revisions: []*pb.SandboxPolicyRevision{ + {Version: 1, PolicyHash: "sha256:v1"}, + }, + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "default") + + require.NoError(t, err) + require.Len(t, revisions, 1) + + // Verify global flag is false by default. + mock.mu.Lock() + assert.False(t, mock.lastListReq.GetGlobal()) + assert.Equal(t, "default", mock.lastListReq.GetWorkspace()) + mock.mu.Unlock() +} + +func TestPolicyList_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.listErr = status.Errorf(codes.NotFound, "sandbox not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + revisions, err := client.List(context.Background(), "default") + + assert.Nil(t, revisions) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestPolicyEditDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.editResp = &pb.EditDraftChunkResponse{} + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + rule := &NetworkPolicyRule{ + Name: "allow-https", + Endpoints: []PolicyNetworkEndpoint{ + {Host: "example.com", Port: 443, Protocol: "tcp"}, + }, + } + + err := client.EditDraftChunk(context.Background(), "default", "my-sandbox", "chunk-1", rule) + + require.NoError(t, err) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastEditReq.GetName()) + assert.Equal(t, "chunk-1", mock.lastEditReq.GetChunkId()) + require.NotNil(t, mock.lastEditReq.GetProposedRule()) + assert.Equal(t, "allow-https", mock.lastEditReq.GetProposedRule().GetName()) + require.Len(t, mock.lastEditReq.GetProposedRule().GetEndpoints(), 1) + assert.Equal(t, "example.com", mock.lastEditReq.GetProposedRule().GetEndpoints()[0].GetHost()) + mock.mu.Unlock() +} + +func TestPolicyEditDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.editErr = status.Errorf(codes.InvalidArgument, "invalid rule") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + err := client.EditDraftChunk(context.Background(), "default", "sb1", "chunk-1", &NetworkPolicyRule{}) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestPolicyUndoDraftChunk(t *testing.T) { + mock := newMockPolicyServer() + mock.undoResp = &pb.UndoDraftChunkResponse{ + PolicyVersion: 10, + PolicyHash: "sha256:undo", + } + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.UndoDraftChunk(context.Background(), "default", "my-sandbox", "chunk-3") + + require.NoError(t, err) + require.NotNil(t, result) + + // Verify request was forwarded. + mock.mu.Lock() + assert.Equal(t, "my-sandbox", mock.lastUndoReq.GetName()) + assert.Equal(t, "chunk-3", mock.lastUndoReq.GetChunkId()) + mock.mu.Unlock() + + assert.Equal(t, uint32(10), result.PolicyVersion) + assert.Equal(t, "sha256:undo", result.PolicyHash) +} + +func TestPolicyUndoDraftChunk_Error(t *testing.T) { + mock := newMockPolicyServer() + mock.undoErr = status.Errorf(codes.NotFound, "chunk not found") + + client, cleanup := setupPolicyTest(t, mock) + defer cleanup() + + result, err := client.UndoDraftChunk(context.Background(), "default", "sb1", "bad-chunk") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} diff --git a/sdk/go/openshell/v1/profile.go b/sdk/go/openshell/v1/profile.go index 7a91632d5a..c0518bc95a 100644 --- a/sdk/go/openshell/v1/profile.go +++ b/sdk/go/openshell/v1/profile.go @@ -55,16 +55,10 @@ const ( // ProfileInterface defines operations for managing provider profiles. type ProfileInterface interface { - // List returns all provider profiles. List(ctx context.Context, workspace string, opts ...ListOptions) ([]*ProviderProfile, error) - // Get retrieves a provider profile by ID. Get(ctx context.Context, workspace, id string) (*ProviderProfile, error) - // Import submits profiles for import and returns the result with diagnostics. Import(ctx context.Context, workspace string, items []ProfileImportItem) (*ImportResult, error) - // Update replaces an existing profile identified by ID and expected resource version. Update(ctx context.Context, workspace, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error) - // Lint validates profiles without persisting them and returns diagnostics. Lint(ctx context.Context, workspace string, items []ProfileImportItem) (*LintResult, error) - // Delete removes a provider profile by ID. Returns true if deleted. Delete(ctx context.Context, workspace, id string) (bool, error) } diff --git a/sdk/go/openshell/v1/profile_client.go b/sdk/go/openshell/v1/profile_client.go new file mode 100644 index 0000000000..67f2d36a4e --- /dev/null +++ b/sdk/go/openshell/v1/profile_client.go @@ -0,0 +1,154 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type profileClient struct { + client pb.OpenShellClient +} + +func newProfileClient(conn grpc.ClientConnInterface) *profileClient { + return &profileClient{client: pb.NewOpenShellClient(conn)} +} + +func (p *profileClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*ProviderProfile, error) { + req := &pb.ListProviderProfilesRequest{ + Workspace: workspace, + } + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + } + + resp, err := p.client.ListProviderProfiles(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + profiles := make([]*ProviderProfile, 0, len(resp.GetProfiles())) + for _, pp := range resp.GetProfiles() { + profiles = append(profiles, converter.ProviderProfileFromProto(pp)) + } + return profiles, nil +} + +func (p *profileClient) Get(ctx context.Context, workspace, id string) (*ProviderProfile, error) { + resp, err := p.client.GetProviderProfile(ctx, &pb.GetProviderProfileRequest{ + Id: id, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderProfileFromProto(resp.GetProfile()), nil +} + +func (p *profileClient) Import(ctx context.Context, workspace string, items []ProfileImportItem) (*ImportResult, error) { + pbItems := make([]*pb.ProviderProfileImportItem, len(items)) + for i := range items { + pbItems[i] = converter.ProfileImportItemToProto(&items[i]) + } + + resp, err := p.client.ImportProviderProfiles(ctx, &pb.ImportProviderProfilesRequest{ + Profiles: pbItems, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &ImportResult{ + Imported: resp.GetImported(), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + for _, pp := range resp.GetProfiles() { + if profile := converter.ProviderProfileFromProto(pp); profile != nil { + result.Profiles = append(result.Profiles, *profile) + } + } + + return result, nil +} + +func (p *profileClient) Update(ctx context.Context, workspace, id string, expectedResourceVersion uint64, item ProfileImportItem) (*UpdateResult, error) { + resp, err := p.client.UpdateProviderProfiles(ctx, &pb.UpdateProviderProfilesRequest{ + Id: id, + Profile: converter.ProfileImportItemToProto(&item), + ExpectedResourceVersion: expectedResourceVersion, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &UpdateResult{ + Updated: resp.GetUpdated(), + Profile: converter.ProviderProfileFromProto(resp.GetProfile()), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + return result, nil +} + +func (p *profileClient) Lint(ctx context.Context, workspace string, items []ProfileImportItem) (*LintResult, error) { + pbItems := make([]*pb.ProviderProfileImportItem, len(items)) + for i := range items { + pbItems[i] = converter.ProfileImportItemToProto(&items[i]) + } + + resp, err := p.client.LintProviderProfiles(ctx, &pb.LintProviderProfilesRequest{ + Profiles: pbItems, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + result := &LintResult{ + Valid: resp.GetValid(), + } + + for _, d := range resp.GetDiagnostics() { + if diag := converter.ProfileDiagnosticFromProto(d); diag != nil { + result.Diagnostics = append(result.Diagnostics, *diag) + } + } + + return result, nil +} + +func (p *profileClient) Delete(ctx context.Context, workspace, id string) (bool, error) { + resp, err := p.client.DeleteProviderProfile(ctx, &pb.DeleteProviderProfileRequest{ + Id: id, + Workspace: workspace, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetDeleted(), nil +} diff --git a/sdk/go/openshell/v1/profile_client_test.go b/sdk/go/openshell/v1/profile_client_test.go new file mode 100644 index 0000000000..b071038c54 --- /dev/null +++ b/sdk/go/openshell/v1/profile_client_test.go @@ -0,0 +1,570 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + sbv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/sandboxv1" + "github.com/stretchr/testify/assert" + "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" +) + +// --- Mock server for provider profiles --- + +type mockProfileServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + profiles map[string]*pb.ProviderProfile // key: profile ID + + listErr error + getErr error + importErr error + updateErr error + lintErr error + deleteErr error + + lastListReq *pb.ListProviderProfilesRequest +} + +func newMockProfileServer() *mockProfileServer { + return &mockProfileServer{ + profiles: make(map[string]*pb.ProviderProfile), + } +} + +func (s *mockProfileServer) ListProviderProfiles(_ context.Context, req *pb.ListProviderProfilesRequest) (*pb.ListProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.lastListReq = req + if s.listErr != nil { + return nil, s.listErr + } + + var profiles []*pb.ProviderProfile + for _, p := range s.profiles { + profiles = append(profiles, p) + } + return &pb.ListProviderProfilesResponse{Profiles: profiles}, nil +} + +func (s *mockProfileServer) GetProviderProfile(_ context.Context, req *pb.GetProviderProfileRequest) (*pb.ProviderProfileResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + + p, ok := s.profiles[req.GetId()] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", req.GetId()) + } + return &pb.ProviderProfileResponse{Profile: p}, nil +} + +func (s *mockProfileServer) ImportProviderProfiles(_ context.Context, req *pb.ImportProviderProfilesRequest) (*pb.ImportProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.importErr != nil { + return nil, s.importErr + } + + var imported []*pb.ProviderProfile + for _, item := range req.GetProfiles() { + p := item.GetProfile() + if p != nil { + s.profiles[p.GetId()] = p + imported = append(imported, p) + } + } + return &pb.ImportProviderProfilesResponse{ + Profiles: imported, + Imported: len(imported) > 0, + }, nil +} + +func (s *mockProfileServer) UpdateProviderProfiles(_ context.Context, req *pb.UpdateProviderProfilesRequest) (*pb.UpdateProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.updateErr != nil { + return nil, s.updateErr + } + + id := req.GetId() + existing, ok := s.profiles[id] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", id) + } + + if req.GetExpectedResourceVersion() != existing.GetResourceVersion() { + return nil, status.Errorf(codes.FailedPrecondition, "resource version mismatch") + } + + p := req.GetProfile().GetProfile() + if p != nil { + p.ResourceVersion = existing.GetResourceVersion() + 1 + s.profiles[id] = p + } + + return &pb.UpdateProviderProfilesResponse{ + Profile: p, + Updated: true, + }, nil +} + +func (s *mockProfileServer) LintProviderProfiles(_ context.Context, req *pb.LintProviderProfilesRequest) (*pb.LintProviderProfilesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.lintErr != nil { + return nil, s.lintErr + } + + // Simple lint: valid if all profiles have an ID + var diagnostics []*pb.ProviderProfileDiagnostic + valid := true + for _, item := range req.GetProfiles() { + p := item.GetProfile() + if p != nil && p.GetId() == "" { + valid = false + diagnostics = append(diagnostics, &pb.ProviderProfileDiagnostic{ + Source: item.GetSource(), + Field: "id", + Message: "profile ID is required", + Severity: "error", + }) + } + } + return &pb.LintProviderProfilesResponse{ + Diagnostics: diagnostics, + Valid: valid, + }, nil +} + +func (s *mockProfileServer) DeleteProviderProfile(_ context.Context, req *pb.DeleteProviderProfileRequest) (*pb.DeleteProviderProfileResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + _, ok := s.profiles[req.GetId()] + if !ok { + return nil, status.Errorf(codes.NotFound, "profile %q not found", req.GetId()) + } + delete(s.profiles, req.GetId()) + return &pb.DeleteProviderProfileResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupProfileTest(t *testing.T, mock *mockProfileServer) (*profileClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newProfileClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// seedProfile adds a profile to the mock server store for testing. +func seedProfile(mock *mockProfileServer, id, displayName string, category pb.ProviderProfileCategory) { + mock.mu.Lock() + defer mock.mu.Unlock() + mock.profiles[id] = &pb.ProviderProfile{ + Id: id, + DisplayName: displayName, + Description: "Test profile " + id, + Category: category, + ResourceVersion: 1, + Credentials: []*pb.ProviderProfileCredential{ + {Name: "api-key", Description: "API Key", Required: true, Refresh: &pb.ProviderCredentialRefresh{}}, + }, + Endpoints: []*sbv1.NetworkEndpoint{ + {Host: "localhost", Port: 8080, Protocol: "http"}, + }, + Binaries: []*sbv1.NetworkBinary{ + {Path: "/usr/bin/provider"}, + }, + } +} + +// --- List tests --- + +func TestProfileList(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + seedProfile(mock, "p2", "Profile Two", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_AGENT) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background(), "default") + + require.NoError(t, err) + assert.Len(t, profiles, 2) +} + +func TestProfileList_Empty(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background(), "default") + + require.NoError(t, err) + assert.Empty(t, profiles) +} + +func TestProfileList_WithOptions(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background(), "default", ListOptions{Limit: 10, Offset: 5}) + + require.NoError(t, err) + assert.Len(t, profiles, 1) + require.NotNil(t, mock.lastListReq) + assert.Equal(t, uint32(10), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(5), mock.lastListReq.GetOffset()) +} + +func TestProfileList_Error(t *testing.T) { + mock := newMockProfileServer() + mock.listErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profiles, err := client.List(context.Background(), "default") + + assert.Nil(t, profiles) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Get tests --- + +func TestProfileGet(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "default", "p1") + + require.NoError(t, err) + require.NotNil(t, profile) + assert.Equal(t, "p1", profile.ID) + assert.Equal(t, "Profile One", profile.DisplayName) + assert.Equal(t, ProfileCategoryInference, profile.Category) + assert.Equal(t, uint64(1), profile.ResourceVersion) + // Verify credential deep copy + require.Len(t, profile.Credentials, 1) + assert.Equal(t, "api-key", profile.Credentials[0].Name) + assert.True(t, profile.Credentials[0].Required) + assert.True(t, profile.Credentials[0].Secret) // derived from Refresh != nil + // Verify endpoint deep copy + require.Len(t, profile.Endpoints, 1) + assert.Equal(t, "localhost", profile.Endpoints[0].Host) + assert.Equal(t, uint32(8080), profile.Endpoints[0].Port) + // Verify binary deep copy + require.Len(t, profile.Binaries, 1) + assert.Equal(t, "/usr/bin/provider", profile.Binaries[0].Path) +} + +func TestProfileGet_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "default", "nonexistent") + + assert.Nil(t, profile) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileGet_Error(t *testing.T) { + mock := newMockProfileServer() + mock.getErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + profile, err := client.Get(context.Background(), "default", "p1") + + assert.Nil(t, profile) + require.Error(t, err) +} + +// --- Import tests --- + +func TestProfileImport(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ + ID: "p1", + DisplayName: "New Profile", + Category: ProfileCategoryInference, + }, + Source: "test.yaml", + }, + } + + result, err := client.Import(context.Background(), "default", items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Imported) + assert.Len(t, result.Profiles, 1) + assert.Equal(t, "p1", result.Profiles[0].ID) +} + +func TestProfileImport_MultipleItems(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "p1", DisplayName: "Profile 1"}, + Source: "a.yaml", + }, + { + Profile: ProviderProfile{ID: "p2", DisplayName: "Profile 2"}, + Source: "b.yaml", + }, + } + + result, err := client.Import(context.Background(), "default", items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Imported) + assert.Len(t, result.Profiles, 2) +} + +func TestProfileImport_Error(t *testing.T) { + mock := newMockProfileServer() + mock.importErr = status.Errorf(codes.InvalidArgument, "bad request") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + {Profile: ProviderProfile{ID: "p1"}, Source: "test.yaml"}, + } + + result, err := client.Import(context.Background(), "default", items) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Update tests --- + +func TestProfileUpdate(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Original", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ + ID: "p1", + DisplayName: "Updated", + Category: ProfileCategoryAgent, + }, + Source: "update.yaml", + } + + result, err := client.Update(context.Background(), "default", "p1", 1, item) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Updated) + require.NotNil(t, result.Profile) + assert.Equal(t, uint64(2), result.Profile.ResourceVersion) // bumped by mock +} + +func TestProfileUpdate_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "missing"}, + Source: "test.yaml", + } + + result, err := client.Update(context.Background(), "default", "missing", 1, item) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileUpdate_VersionMismatch(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Original", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "p1"}, + Source: "test.yaml", + } + + // Use wrong version (99 instead of 1) + result, err := client.Update(context.Background(), "default", "p1", 99, item) + + assert.Nil(t, result) + require.Error(t, err) +} + +func TestProfileUpdate_Error(t *testing.T) { + mock := newMockProfileServer() + mock.updateErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + item := ProfileImportItem{ + Profile: ProviderProfile{ID: "p1"}, + Source: "test.yaml", + } + + result, err := client.Update(context.Background(), "default", "p1", 1, item) + + assert.Nil(t, result) + require.Error(t, err) +} + +// --- Lint tests --- + +func TestProfileLint_Valid(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "p1", DisplayName: "Good Profile"}, + Source: "test.yaml", + }, + } + + result, err := client.Lint(context.Background(), "default", items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.True(t, result.Valid) + assert.Empty(t, result.Diagnostics) +} + +func TestProfileLint_Invalid(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + { + Profile: ProviderProfile{ID: "", DisplayName: "Bad Profile"}, // empty ID triggers lint error + Source: "bad.yaml", + }, + } + + result, err := client.Lint(context.Background(), "default", items) + + require.NoError(t, err) + require.NotNil(t, result) + assert.False(t, result.Valid) + require.Len(t, result.Diagnostics, 1) + assert.Equal(t, "id", result.Diagnostics[0].Field) + assert.Equal(t, "error", result.Diagnostics[0].Severity) +} + +func TestProfileLint_Error(t *testing.T) { + mock := newMockProfileServer() + mock.lintErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + items := []ProfileImportItem{ + {Profile: ProviderProfile{ID: "p1"}, Source: "test.yaml"}, + } + + result, err := client.Lint(context.Background(), "default", items) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Delete tests --- + +func TestProfileDelete(t *testing.T) { + mock := newMockProfileServer() + seedProfile(mock, "p1", "Profile One", pb.ProviderProfileCategory_PROVIDER_PROFILE_CATEGORY_INFERENCE) + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "default", "p1") + + require.NoError(t, err) + assert.True(t, deleted) + + // Verify subsequent Get returns NotFound + profile, err := client.Get(context.Background(), "default", "p1") + assert.Nil(t, profile) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileDelete_NotFound(t *testing.T) { + mock := newMockProfileServer() + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "default", "nonexistent") + + assert.False(t, deleted) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProfileDelete_Error(t *testing.T) { + mock := newMockProfileServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupProfileTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "default", "p1") + + assert.False(t, deleted) + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/provider_client.go b/sdk/go/openshell/v1/provider_client.go new file mode 100644 index 0000000000..19784b6346 --- /dev/null +++ b/sdk/go/openshell/v1/provider_client.go @@ -0,0 +1,130 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type providerClient struct { + client pb.OpenShellClient + profiles *profileClient + refresh *refreshClient +} + +func newProviderClient(conn grpc.ClientConnInterface) *providerClient { + return &providerClient{ + client: pb.NewOpenShellClient(conn), + profiles: newProfileClient(conn), + refresh: newRefreshClient(conn), + } +} + +func (p *providerClient) Profiles() ProfileInterface { + return p.profiles +} + +func (p *providerClient) Refresh() RefreshInterface { + return p.refresh +} + +func (p *providerClient) Create(ctx context.Context, workspace string, provider *Provider) (*Provider, error) { + resp, err := p.client.CreateProvider(ctx, &pb.CreateProviderRequest{ + Provider: converter.ProviderToProto(provider), + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) Get(ctx context.Context, workspace, name string) (*Provider, error) { + resp, err := p.client.GetProvider(ctx, &pb.GetProviderRequest{ + Name: name, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Provider, error) { + req := &pb.ListProvidersRequest{ + Workspace: workspace, + } + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + req.AllWorkspaces = opts[0].AllWorkspaces + } + + resp, err := p.client.ListProviders(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + providers := make([]*Provider, 0, len(resp.GetProviders())) + for _, proto := range resp.GetProviders() { + providers = append(providers, converter.ProviderFromProto(proto)) + } + return providers, nil +} + +func (p *providerClient) Update(ctx context.Context, workspace string, provider *Provider) (*Provider, error) { + proto := converter.ProviderToProto(provider) + req := &pb.UpdateProviderRequest{ + Provider: proto, + Workspace: workspace, + } + if proto != nil { + req.CredentialExpiresAtMs = proto.CredentialExpiresAtMs + } + + resp, err := p.client.UpdateProvider(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ProviderFromProto(resp.GetProvider()), nil +} + +func (p *providerClient) Delete(ctx context.Context, workspace, name string) error { + _, err := p.client.DeleteProvider(ctx, &pb.DeleteProviderRequest{ + Name: name, + Workspace: workspace, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (p *providerClient) Ensure(ctx context.Context, workspace string, provider *Provider) (*Provider, error) { + if provider == nil { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "provider must not be nil"} + } + existing, err := p.Get(ctx, workspace, provider.Name) + if err != nil { + if !IsNotFound(err) { + return nil, err + } + return p.Create(ctx, workspace, provider) + } + + updated := *provider + updated.ID = existing.ID + updated.ResourceVersion = existing.ResourceVersion + return p.Update(ctx, workspace, &updated) +} diff --git a/sdk/go/openshell/v1/provider_client_test.go b/sdk/go/openshell/v1/provider_client_test.go new file mode 100644 index 0000000000..55edddf16d --- /dev/null +++ b/sdk/go/openshell/v1/provider_client_test.go @@ -0,0 +1,312 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "testing" + + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +type mockProviderServer struct { + pb.UnimplementedOpenShellServer + providers map[string]*dm.Provider + createErr error + getErr error + listErr error + updateErr error + deleteErr error +} + +func newMockProviderServer() *mockProviderServer { + return &mockProviderServer{ + providers: make(map[string]*dm.Provider), + } +} + +func (s *mockProviderServer) CreateProvider(_ context.Context, req *pb.CreateProviderRequest) (*pb.ProviderResponse, error) { + if s.createErr != nil { + return nil, s.createErr + } + p := req.GetProvider() + if p.GetMetadata() != nil { + s.providers[p.GetMetadata().GetName()] = p + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) GetProvider(_ context.Context, req *pb.GetProviderRequest) (*pb.ProviderResponse, error) { + if s.getErr != nil { + return nil, s.getErr + } + p, ok := s.providers[req.GetName()] + if !ok { + return nil, status.Errorf(codes.NotFound, "provider %q not found", req.GetName()) + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) ListProviders(_ context.Context, _ *pb.ListProvidersRequest) (*pb.ListProvidersResponse, error) { + if s.listErr != nil { + return nil, s.listErr + } + var list []*dm.Provider + for _, p := range s.providers { + list = append(list, p) + } + return &pb.ListProvidersResponse{Providers: list}, nil +} + +func (s *mockProviderServer) UpdateProvider(_ context.Context, req *pb.UpdateProviderRequest) (*pb.ProviderResponse, error) { + if s.updateErr != nil { + return nil, s.updateErr + } + p := req.GetProvider() + if p.GetMetadata() != nil { + name := p.GetMetadata().GetName() + if _, ok := s.providers[name]; !ok { + return nil, status.Errorf(codes.NotFound, "provider %q not found", name) + } + s.providers[name] = p + } + return &pb.ProviderResponse{Provider: p}, nil +} + +func (s *mockProviderServer) DeleteProvider(_ context.Context, req *pb.DeleteProviderRequest) (*pb.DeleteProviderResponse, error) { + if s.deleteErr != nil { + return nil, s.deleteErr + } + delete(s.providers, req.GetName()) + return &pb.DeleteProviderResponse{}, nil +} + +func setupProviderTest(t *testing.T, mock *mockProviderServer) (*providerClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newProviderClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +func TestProviderCreate(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "my-claude", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"API_KEY": "secret"}, + Config: map[string]string{"region": "us-east-1"}, + }, + } + + result, err := client.Create(context.Background(), "default", p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "my-claude", result.Name) + assert.Equal(t, "claude", result.Type) + assert.Nil(t, result.Spec.Credentials, "credentials are write-only and should not be returned") +} + +func TestProviderCreate_AlreadyExists(t *testing.T) { + mock := newMockProviderServer() + mock.createErr = status.Error(codes.AlreadyExists, "provider already exists") + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Create(context.Background(), "default", &Provider{Name: "dup"}) + + require.Error(t, err) + assert.True(t, IsAlreadyExists(err)) +} + +func TestProviderGet(t *testing.T) { + mock := newMockProviderServer() + mock.providers["existing"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Id: "p1", Name: "existing"}, + Type: "gitlab", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.Get(context.Background(), "default", "existing") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "existing", result.Name) + assert.Equal(t, "gitlab", result.Type) +} + +func TestProviderGet_NotFound(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Get(context.Background(), "default", "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderList(t *testing.T) { + mock := newMockProviderServer() + mock.providers["p1"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "p1"}, + Type: "claude", + } + mock.providers["p2"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "p2"}, + Type: "gitlab", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background(), "default") + + require.NoError(t, err) + assert.Len(t, result, 2) +} + +func TestProviderList_Empty(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + result, err := client.List(context.Background(), "default") + + require.NoError(t, err) + assert.Empty(t, result) +} + +func TestProviderUpdate(t *testing.T) { + mock := newMockProviderServer() + mock.providers["updatable"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "updatable"}, + Type: "claude", + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "updatable", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"API_KEY": "new-secret"}, + }, + } + + result, err := client.Update(context.Background(), "default", p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "updatable", result.Name) +} + +func TestProviderUpdate_NotFound(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + _, err := client.Update(context.Background(), "default", &Provider{Name: "missing"}) + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderDelete(t *testing.T) { + mock := newMockProviderServer() + mock.providers["deleteme"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "deleteme"}, + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "default", "deleteme") + + require.NoError(t, err) + assert.Empty(t, mock.providers["deleteme"]) +} + +func TestProviderDelete_NotFound(t *testing.T) { + mock := newMockProviderServer() + mock.deleteErr = status.Error(codes.NotFound, "not found") + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "default", "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestProviderEnsure_Creates(t *testing.T) { + mock := newMockProviderServer() + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "new-provider", + Type: "claude", + Spec: ProviderSpec{ + Credentials: map[string]string{"KEY": "val"}, + }, + } + + result, err := client.Ensure(context.Background(), "default", p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "new-provider", result.Name) +} + +func TestProviderEnsure_Updates(t *testing.T) { + mock := newMockProviderServer() + mock.providers["existing"] = &dm.Provider{ + Metadata: &dm.ObjectMeta{Name: "existing"}, + Type: "claude", + Config: map[string]string{"old": "config"}, + } + client, cleanup := setupProviderTest(t, mock) + defer cleanup() + + p := &Provider{ + Name: "existing", + Type: "claude", + Spec: ProviderSpec{ + Config: map[string]string{"new": "config"}, + }, + } + + result, err := client.Ensure(context.Background(), "default", p) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "existing", result.Name) +} diff --git a/sdk/go/openshell/v1/refresh.go b/sdk/go/openshell/v1/refresh.go index 6aec9bbc52..53c39a0ac9 100644 --- a/sdk/go/openshell/v1/refresh.go +++ b/sdk/go/openshell/v1/refresh.go @@ -25,18 +25,12 @@ const ( RefreshStrategyOAuth2RefreshToken = types.RefreshStrategyOAuth2RefreshToken RefreshStrategyOAuth2ClientCredentials = types.RefreshStrategyOAuth2ClientCredentials RefreshStrategyGoogleServiceAccountJWT = types.RefreshStrategyGoogleServiceAccountJWT - RefreshStrategyAWSStsAssumeRole = types.RefreshStrategyAWSStsAssumeRole ) // RefreshInterface defines operations for managing provider credential refresh. type RefreshInterface interface { - // GetStatus returns the refresh status for a provider's credential. - // If credentialKey is empty, statuses for all credentials are returned. GetStatus(ctx context.Context, workspace, provider, credentialKey string) ([]*RefreshStatus, error) - // Configure sets up credential refresh for a provider credential. Configure(ctx context.Context, workspace string, config *RefreshConfig) (*RefreshStatus, error) - // Rotate triggers an immediate credential rotation. Rotate(ctx context.Context, workspace, provider, credentialKey string) (*RefreshStatus, error) - // Delete removes credential refresh configuration. Returns true if deleted. Delete(ctx context.Context, workspace, provider, credentialKey string) (bool, error) } diff --git a/sdk/go/openshell/v1/refresh_client.go b/sdk/go/openshell/v1/refresh_client.go new file mode 100644 index 0000000000..ec98316a93 --- /dev/null +++ b/sdk/go/openshell/v1/refresh_client.go @@ -0,0 +1,71 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type refreshClient struct { + client pb.OpenShellClient +} + +func newRefreshClient(conn grpc.ClientConnInterface) *refreshClient { + return &refreshClient{client: pb.NewOpenShellClient(conn)} +} + +func (r *refreshClient) GetStatus(ctx context.Context, workspace, provider, credentialKey string) ([]*RefreshStatus, error) { + resp, err := r.client.GetProviderRefreshStatus(ctx, &pb.GetProviderRefreshStatusRequest{ + Provider: provider, + CredentialKey: credentialKey, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + statuses := make([]*RefreshStatus, 0, len(resp.GetCredentials())) + for _, s := range resp.GetCredentials() { + statuses = append(statuses, converter.RefreshStatusFromProto(s)) + } + return statuses, nil +} + +func (r *refreshClient) Configure(ctx context.Context, workspace string, config *RefreshConfig) (*RefreshStatus, error) { + req := converter.RefreshConfigToProto(config) + req.Workspace = workspace + resp, err := r.client.ConfigureProviderRefresh(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.RefreshStatusFromProto(resp.GetStatus()), nil +} + +func (r *refreshClient) Rotate(ctx context.Context, workspace, provider, credentialKey string) (*RefreshStatus, error) { + resp, err := r.client.RotateProviderCredential(ctx, &pb.RotateProviderCredentialRequest{ + Provider: provider, + CredentialKey: credentialKey, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.RefreshStatusFromProto(resp.GetStatus()), nil +} + +func (r *refreshClient) Delete(ctx context.Context, workspace, provider, credentialKey string) (bool, error) { + resp, err := r.client.DeleteProviderRefresh(ctx, &pb.DeleteProviderRefreshRequest{ + Provider: provider, + CredentialKey: credentialKey, + Workspace: workspace, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetDeleted(), nil +} diff --git a/sdk/go/openshell/v1/refresh_client_test.go b/sdk/go/openshell/v1/refresh_client_test.go new file mode 100644 index 0000000000..7c69cb093a --- /dev/null +++ b/sdk/go/openshell/v1/refresh_client_test.go @@ -0,0 +1,426 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +// --- Mock server for credential refresh --- + +type mockRefreshServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + statuses map[string]*pb.ProviderCredentialRefreshStatus // key: "provider/credentialKey" + getStatusErr error + configureErr error + rotateErr error + deleteErr error +} + +func newMockRefreshServer() *mockRefreshServer { + return &mockRefreshServer{ + statuses: make(map[string]*pb.ProviderCredentialRefreshStatus), + } +} + +func refreshKey(provider, credentialKey string) string { + return provider + "/" + credentialKey +} + +func (s *mockRefreshServer) GetProviderRefreshStatus(_ context.Context, req *pb.GetProviderRefreshStatusRequest) (*pb.GetProviderRefreshStatusResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getStatusErr != nil { + return nil, s.getStatusErr + } + + var creds []*pb.ProviderCredentialRefreshStatus + if req.GetCredentialKey() != "" { + // Return specific credential + st, ok := s.statuses[refreshKey(req.GetProvider(), req.GetCredentialKey())] + if ok { + creds = append(creds, st) + } + } else { + // Return all credentials for provider + for key, st := range s.statuses { + if len(key) > len(req.GetProvider()) && key[:len(req.GetProvider())+1] == req.GetProvider()+"/" { + creds = append(creds, st) + } + } + } + return &pb.GetProviderRefreshStatusResponse{Credentials: creds}, nil +} + +func (s *mockRefreshServer) ConfigureProviderRefresh(_ context.Context, req *pb.ConfigureProviderRefreshRequest) (*pb.ConfigureProviderRefreshResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.configureErr != nil { + return nil, s.configureErr + } + + st := &pb.ProviderCredentialRefreshStatus{ + ProviderName: req.GetProvider(), + ProviderId: "prov-id-" + req.GetProvider(), + CredentialKey: req.GetCredentialKey(), + Strategy: req.GetStrategy(), + Status: "active", + ExpiresAtMs: req.GetExpiresAtMs(), + } + s.statuses[refreshKey(req.GetProvider(), req.GetCredentialKey())] = st + return &pb.ConfigureProviderRefreshResponse{Status: st}, nil +} + +func (s *mockRefreshServer) RotateProviderCredential(_ context.Context, req *pb.RotateProviderCredentialRequest) (*pb.RotateProviderCredentialResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.rotateErr != nil { + return nil, s.rotateErr + } + + key := refreshKey(req.GetProvider(), req.GetCredentialKey()) + st, ok := s.statuses[key] + if !ok { + return nil, status.Errorf(codes.NotFound, "refresh config %q not found", key) + } + st.Status = "rotated" + st.LastRefreshAtMs = time.Now().UnixMilli() + return &pb.RotateProviderCredentialResponse{Status: st}, nil +} + +func (s *mockRefreshServer) DeleteProviderRefresh(_ context.Context, req *pb.DeleteProviderRefreshRequest) (*pb.DeleteProviderRefreshResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + key := refreshKey(req.GetProvider(), req.GetCredentialKey()) + _, ok := s.statuses[key] + if !ok { + return &pb.DeleteProviderRefreshResponse{Deleted: false}, nil + } + delete(s.statuses, key) + return &pb.DeleteProviderRefreshResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupRefreshTest(t *testing.T, mock *mockRefreshServer) (*refreshClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newRefreshClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- GetStatus tests --- + +func TestRefreshGetStatus(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure a credential first + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + Material: map[string]string{"refresh_token": "tok-123"}, + } + _, err := client.Configure(context.Background(), "default", cfg) + require.NoError(t, err) + + // Get status for specific credential + statuses, err := client.GetStatus(context.Background(), "default", "openai", "api-key") + + require.NoError(t, err) + require.Len(t, statuses, 1) + assert.Equal(t, "openai", statuses[0].ProviderName) + assert.Equal(t, "api-key", statuses[0].CredentialKey) + assert.Equal(t, RefreshStrategyOAuth2RefreshToken, statuses[0].Strategy) + assert.Equal(t, "active", statuses[0].Status) +} + +func TestRefreshGetStatus_AllCredentials(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure two credentials + _, err := client.Configure(context.Background(), "default", &RefreshConfig{ + Provider: "openai", + CredentialKey: "key-1", + Strategy: RefreshStrategyStatic, + }) + require.NoError(t, err) + _, err = client.Configure(context.Background(), "default", &RefreshConfig{ + Provider: "openai", + CredentialKey: "key-2", + Strategy: RefreshStrategyExternal, + }) + require.NoError(t, err) + + // Get all statuses (empty credentialKey) + statuses, err := client.GetStatus(context.Background(), "default", "openai", "") + + require.NoError(t, err) + assert.Len(t, statuses, 2) +} + +func TestRefreshGetStatus_Empty(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + statuses, err := client.GetStatus(context.Background(), "default", "openai", "nonexistent") + + require.NoError(t, err) + assert.Empty(t, statuses) +} + +func TestRefreshGetStatus_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.getStatusErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + statuses, err := client.GetStatus(context.Background(), "default", "openai", "key") + + assert.Nil(t, statuses) + require.Error(t, err) +} + +// --- Configure tests --- + +func TestRefreshConfigure(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + expires := time.Date(2026, 12, 31, 23, 59, 59, 0, time.UTC) + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2ClientCredentials, + Material: map[string]string{"client_id": "id-1", "client_secret": "sec-1"}, + SecretMaterialKeys: []string{"client_secret"}, + ExpiresAt: &expires, + } + + result, err := client.Configure(context.Background(), "default", cfg) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "openai", result.ProviderName) + assert.Equal(t, "prov-id-openai", result.ProviderID) + assert.Equal(t, "api-key", result.CredentialKey) + assert.Equal(t, RefreshStrategyOAuth2ClientCredentials, result.Strategy) + assert.Equal(t, "active", result.Status) +} + +func TestRefreshConfigure_MinimalConfig(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + cfg := &RefreshConfig{ + Provider: "anthropic", + CredentialKey: "key", + Strategy: RefreshStrategyStatic, + } + + result, err := client.Configure(context.Background(), "default", cfg) + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "anthropic", result.ProviderName) + assert.Equal(t, RefreshStrategyStatic, result.Strategy) +} + +func TestRefreshConfigure_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.configureErr = status.Errorf(codes.InvalidArgument, "invalid config") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "key", + Strategy: RefreshStrategyStatic, + } + + result, err := client.Configure(context.Background(), "default", cfg) + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Rotate tests --- + +func TestRefreshRotate(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure first + _, err := client.Configure(context.Background(), "default", &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + }) + require.NoError(t, err) + + // Rotate + result, err := client.Rotate(context.Background(), "default", "openai", "api-key") + + require.NoError(t, err) + require.NotNil(t, result) + assert.Equal(t, "rotated", result.Status) + assert.False(t, result.LastRefreshAt.IsZero()) +} + +func TestRefreshRotate_NotFound(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + result, err := client.Rotate(context.Background(), "default", "openai", "nonexistent") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestRefreshRotate_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.rotateErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + result, err := client.Rotate(context.Background(), "default", "openai", "key") + + assert.Nil(t, result) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +// --- Delete tests --- + +func TestRefreshDelete(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + // Configure first + _, err := client.Configure(context.Background(), "default", &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyStatic, + }) + require.NoError(t, err) + + // Delete + deleted, err := client.Delete(context.Background(), "default", "openai", "api-key") + + require.NoError(t, err) + assert.True(t, deleted) + + // Verify it's gone + statuses, err := client.GetStatus(context.Background(), "default", "openai", "api-key") + require.NoError(t, err) + assert.Empty(t, statuses) +} + +func TestRefreshDelete_NotConfigured(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "default", "openai", "nonexistent") + + require.NoError(t, err) + assert.False(t, deleted) +} + +func TestRefreshDelete_Error(t *testing.T) { + mock := newMockRefreshServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + deleted, err := client.Delete(context.Background(), "default", "openai", "key") + + assert.False(t, deleted) + require.Error(t, err) +} + +// --- Integration test: full lifecycle --- + +func TestRefreshLifecycle(t *testing.T) { + mock := newMockRefreshServer() + client, cleanup := setupRefreshTest(t, mock) + defer cleanup() + + ctx := context.Background() + + // 1. Configure + cfg := &RefreshConfig{ + Provider: "openai", + CredentialKey: "api-key", + Strategy: RefreshStrategyOAuth2RefreshToken, + Material: map[string]string{"refresh_token": "tok-123"}, + } + st, err := client.Configure(ctx, "default", cfg) + require.NoError(t, err) + assert.Equal(t, "active", st.Status) + + // 2. GetStatus + statuses, err := client.GetStatus(ctx, "default", "openai", "api-key") + require.NoError(t, err) + require.Len(t, statuses, 1) + + // 3. Rotate + rotated, err := client.Rotate(ctx, "default", "openai", "api-key") + require.NoError(t, err) + assert.Equal(t, "rotated", rotated.Status) + + // 4. Delete + deleted, err := client.Delete(ctx, "default", "openai", "api-key") + require.NoError(t, err) + assert.True(t, deleted) + + // 5. Verify removed + statuses, err = client.GetStatus(ctx, "default", "openai", "api-key") + require.NoError(t, err) + assert.Empty(t, statuses) +} diff --git a/sdk/go/openshell/v1/sandbox.go b/sdk/go/openshell/v1/sandbox.go index 2dfc6ba8ac..5c50274a69 100644 --- a/sdk/go/openshell/v1/sandbox.go +++ b/sdk/go/openshell/v1/sandbox.go @@ -53,7 +53,7 @@ var WithLogMinLevel = types.WithLogMinLevel // SandboxInterface defines lifecycle operations on sandboxes. type SandboxInterface interface { - Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) + Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) Get(ctx context.Context, workspace, name string) (*Sandbox, error) List(ctx context.Context, workspace string, opts ...ListOptions) ([]*Sandbox, error) Delete(ctx context.Context, workspace, name string) error @@ -62,12 +62,5 @@ type SandboxInterface interface { ListProviders(ctx context.Context, workspace, sandboxName string) ([]*Provider, error) WaitReady(ctx context.Context, workspace, name string, opts ...WaitOptions) (*Sandbox, error) Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) - // GetLogs retrieves log entries for a sandbox. The sandbox is resolved - // by name (an internal Get call translates name to ID). Use - // WithLogLines, WithLogSince, WithLogSources, and WithLogMinLevel to - // filter the results. - // - // Errors: NotFound if the sandbox does not exist; InvalidArgument if - // the sandbox name is empty; Unimplemented by the fake client. GetLogs(ctx context.Context, workspace, sandboxName string, opts ...LogOption) (*LogResult, error) } diff --git a/sdk/go/openshell/v1/sandbox_client.go b/sdk/go/openshell/v1/sandbox_client.go index 6c38db7811..543bc28063 100644 --- a/sdk/go/openshell/v1/sandbox_client.go +++ b/sdk/go/openshell/v1/sandbox_client.go @@ -25,17 +25,21 @@ func newSandboxClient(conn grpc.ClientConnInterface) *sandboxClient { return &sandboxClient{client: pb.NewOpenShellClient(conn)} } -func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string) (*Sandbox, error) { - pbSpec, err := converter.SandboxSpecToProto(spec) +func (s *sandboxClient) Create(ctx context.Context, workspace, name string, spec *SandboxSpec, labels map[string]string, opts ...CreateOptions) (*Sandbox, error) { + protoSpec, err := converter.SandboxSpecToProtoChecked(spec) if err != nil { return nil, &StatusError{Code: ErrorInvalidArgument, Message: err.Error()} } - resp, err := s.client.CreateSandbox(ctx, &pb.CreateSandboxRequest{ + req := &pb.CreateSandboxRequest{ Name: name, - Spec: pbSpec, + Spec: protoSpec, Labels: labels, Workspace: workspace, - }) + } + if len(opts) > 0 { + req.Annotations = converter.CopyStringMap(opts[0].Annotations) + } + resp, err := s.client.CreateSandbox(ctx, req) if err != nil { return nil, converter.FromGRPCError(err) } @@ -58,12 +62,14 @@ func (s *sandboxClient) List(ctx context.Context, workspace string, opts ...List Workspace: workspace, } if len(opts) > 0 { - if opts[0].Limit > 0 { - req.Limit = uint32(opts[0].Limit) + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} } - if opts[0].Offset > 0 { - req.Offset = uint32(opts[0].Offset) + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) req.LabelSelector = opts[0].LabelSelector req.AllWorkspaces = opts[0].AllWorkspaces } @@ -150,14 +156,8 @@ func (s *sandboxClient) WaitReady(ctx context.Context, workspace, name string, o return nil, err } - if sb.Status.Phase == SandboxReady { - return sb, nil - } - if sb.Status.Phase == SandboxError { - return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} - } - if sb.Status.Phase == SandboxDeleting { - return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is being deleted", name)} + if result, termErr := checkTerminalPhase(sb, name); result != nil || termErr != nil { + return result, termErr } ticker := time.NewTicker(interval) @@ -172,19 +172,26 @@ func (s *sandboxClient) WaitReady(ctx context.Context, workspace, name string, o if err != nil { return nil, err } - if sb.Status.Phase == SandboxReady { - return sb, nil - } - if sb.Status.Phase == SandboxError { - return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} - } - if sb.Status.Phase == SandboxDeleting { - return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is being deleted", name)} + if result, termErr := checkTerminalPhase(sb, name); result != nil || termErr != nil { + return result, termErr } } } } +func checkTerminalPhase(sb *Sandbox, name string) (*Sandbox, error) { + switch sb.Status.Phase { + case SandboxReady: + return sb, nil + case SandboxError: + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is in error state", name)} + case SandboxDeleting: + return nil, &StatusError{Code: ErrorInternal, Message: fmt.Sprintf("sandbox %q is being deleted", name)} + default: + return nil, nil + } +} + func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts ...WatchOptions) (WatchInterface[*Sandbox], error) { if name == "" { return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} @@ -195,7 +202,6 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts watchOpts = opts[0] } - // Resolve sandbox name to ID — the proto RPC takes Id, not name. sb, err := s.Get(ctx, workspace, name) if err != nil { return nil, err @@ -241,7 +247,6 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts case <-w.done: return } - // StopOnTerminal: close watcher after delivering a terminal phase event if watchOpts.StopOnTerminal && (sandbox.Status.Phase == SandboxReady || sandbox.Status.Phase == SandboxError) { w.Stop() return @@ -251,6 +256,11 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts ev, recvErr = stream.Recv() if recvErr != nil { if recvErr != io.EOF { + select { + case <-w.done: + return + default: + } select { case ch <- Event[*Sandbox]{Type: EventError, Err: converter.FromGRPCError(recvErr)}: case <-w.done: @@ -265,7 +275,6 @@ func (s *sandboxClient) Watch(ctx context.Context, workspace, name string, opts } func (s *sandboxClient) GetLogs(ctx context.Context, workspace, sandboxName string, opts ...LogOption) (*LogResult, error) { - // Resolve sandbox name to ID — the proto RPC takes SandboxId, not name. sb, err := s.Get(ctx, workspace, sandboxName) if err != nil { return nil, err diff --git a/sdk/go/openshell/v1/sandbox_client_test.go b/sdk/go/openshell/v1/sandbox_client_test.go index 2574348ec4..1609e0a44a 100644 --- a/sdk/go/openshell/v1/sandbox_client_test.go +++ b/sdk/go/openshell/v1/sandbox_client_test.go @@ -22,8 +22,6 @@ import ( "google.golang.org/protobuf/proto" ) -const bufSize = 1024 * 1024 - type mockSandboxServer struct { pb.UnimplementedOpenShellServer mu sync.Mutex @@ -245,6 +243,21 @@ func TestSandboxCreate(t *testing.T) { assert.Equal(t, SandboxProvisioning, result.Status.Phase) } +func TestSandboxCreate_RejectsUnrepresentableResourcesBeforeRPC(t *testing.T) { + mock := newMockSandboxServer() + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.Create(context.Background(), "default", "bad", &SandboxSpec{ + Template: &SandboxTemplate{Resources: map[string]any{"invalid": make(chan int)}}, + }, nil) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) + mock.mu.Lock() + defer mock.mu.Unlock() + assert.Empty(t, mock.sandboxes) +} + func TestSandboxCreate_AlreadyExists(t *testing.T) { mock := newMockSandboxServer() mock.createErr = status.Error(codes.AlreadyExists, "sandbox already exists") @@ -566,6 +579,44 @@ func TestSandboxWaitReady_SandboxFailed(t *testing.T) { require.Error(t, err) } +func TestSandboxWaitReady_SandboxDeleting(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["deleting-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "deleting-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_DELETING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + _, err := client.WaitReady(context.Background(), "default", "deleting-sb") + + require.Error(t, err) + assert.Contains(t, err.Error(), "being deleted") +} + +func TestSandboxWaitReady_BecomesDeleting(t *testing.T) { + mock := newMockSandboxServer() + mock.sandboxes["del-sb"] = &pb.Sandbox{ + Metadata: &dm.ObjectMeta{Name: "del-sb"}, + Status: &pb.SandboxStatus{Phase: pb.SandboxPhase_SANDBOX_PHASE_PROVISIONING}, + } + client, cleanup := setupSandboxTest(t, mock) + defer cleanup() + + go func() { + time.Sleep(50 * time.Millisecond) + mock.setPhase("del-sb", pb.SandboxPhase_SANDBOX_PHASE_DELETING) + }() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, err := client.WaitReady(ctx, "default", "del-sb", WaitOptions{PollInterval: 20 * time.Millisecond}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "being deleted") +} + func TestSandboxWaitReady_NotFound(t *testing.T) { mock := newMockSandboxServer() client, cleanup := setupSandboxTest(t, mock) diff --git a/sdk/go/openshell/v1/service.go b/sdk/go/openshell/v1/service.go index 8d3f3c0f54..4ee819c522 100644 --- a/sdk/go/openshell/v1/service.go +++ b/sdk/go/openshell/v1/service.go @@ -14,12 +14,8 @@ type ServiceEndpoint = types.ServiceEndpoint // ServiceInterface defines operations for managing sandbox service endpoints. type ServiceInterface interface { - // Expose creates a new service endpoint in the given sandbox. Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) - // Get retrieves a service endpoint by sandbox and service name. Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error) - // List returns all service endpoints for a sandbox. An empty sandboxName returns endpoints across all sandboxes. List(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) - // Delete removes a service endpoint by sandbox and service name. Delete(ctx context.Context, workspace, sandboxName, serviceName string) error } diff --git a/sdk/go/openshell/v1/service_client.go b/sdk/go/openshell/v1/service_client.go new file mode 100644 index 0000000000..a16dd0dc05 --- /dev/null +++ b/sdk/go/openshell/v1/service_client.go @@ -0,0 +1,87 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type serviceClient struct { + client pb.OpenShellClient +} + +func newServiceClient(conn grpc.ClientConnInterface) *serviceClient { + return &serviceClient{client: pb.NewOpenShellClient(conn)} +} + +func (s *serviceClient) Expose(ctx context.Context, workspace, sandboxName, serviceName string, targetPort uint32, domain bool) (*ServiceEndpoint, error) { + resp, err := s.client.ExposeService(ctx, &pb.ExposeServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + TargetPort: targetPort, + Domain: domain, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ServiceEndpointFromProto(resp), nil +} + +func (s *serviceClient) Get(ctx context.Context, workspace, sandboxName, serviceName string) (*ServiceEndpoint, error) { + resp, err := s.client.GetService(ctx, &pb.GetServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + Workspace: workspace, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.ServiceEndpointFromProto(resp), nil +} + +func (s *serviceClient) List(ctx context.Context, workspace, sandboxName string, opts ...ListOptions) ([]*ServiceEndpoint, error) { + req := &pb.ListServicesRequest{ + Sandbox: sandboxName, + Workspace: workspace, + } + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + req.AllWorkspaces = opts[0].AllWorkspaces + } + + resp, err := s.client.ListServices(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + endpoints := make([]*ServiceEndpoint, 0, len(resp.GetServices())) + for _, svc := range resp.GetServices() { + endpoints = append(endpoints, converter.ServiceEndpointFromProto(svc)) + } + return endpoints, nil +} + +func (s *serviceClient) Delete(ctx context.Context, workspace, sandboxName, serviceName string) error { + _, err := s.client.DeleteService(ctx, &pb.DeleteServiceRequest{ + Sandbox: sandboxName, + Service: serviceName, + Workspace: workspace, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} diff --git a/sdk/go/openshell/v1/service_client_test.go b/sdk/go/openshell/v1/service_client_test.go new file mode 100644 index 0000000000..334acd215a --- /dev/null +++ b/sdk/go/openshell/v1/service_client_test.go @@ -0,0 +1,322 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "sync" + "testing" + + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +// --- Mock server for service endpoints --- + +type mockServiceServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + endpoints map[string]*pb.ServiceEndpointResponse // key: "sandbox/service" + exposeErr error + getErr error + listErr error + deleteErr error +} + +func newMockServiceServer() *mockServiceServer { + return &mockServiceServer{ + endpoints: make(map[string]*pb.ServiceEndpointResponse), + } +} + +func serviceKey(sandbox, service string) string { + return sandbox + "/" + service +} + +func (s *mockServiceServer) ExposeService(_ context.Context, req *pb.ExposeServiceRequest) (*pb.ServiceEndpointResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.exposeErr != nil { + return nil, s.exposeErr + } + + resp := &pb.ServiceEndpointResponse{ + Endpoint: &pb.ServiceEndpoint{ + Metadata: &dm.ObjectMeta{ + Id: "ep-" + req.GetService(), + }, + SandboxName: req.GetSandbox(), + ServiceName: req.GetService(), + TargetPort: req.GetTargetPort(), + Domain: req.GetDomain(), + }, + } + if req.GetDomain() { + resp.Url = "https://" + req.GetService() + ".example.com" + } + + s.endpoints[serviceKey(req.GetSandbox(), req.GetService())] = resp + return resp, nil +} + +func (s *mockServiceServer) GetService(_ context.Context, req *pb.GetServiceRequest) (*pb.ServiceEndpointResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.getErr != nil { + return nil, s.getErr + } + + ep, ok := s.endpoints[serviceKey(req.GetSandbox(), req.GetService())] + if !ok { + return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), req.GetSandbox()) + } + return ep, nil +} + +func (s *mockServiceServer) ListServices(_ context.Context, req *pb.ListServicesRequest) (*pb.ListServicesResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.listErr != nil { + return nil, s.listErr + } + + var services []*pb.ServiceEndpointResponse + for key, ep := range s.endpoints { + prefix := req.GetSandbox() + "/" + if req.GetSandbox() == "" || (len(key) >= len(prefix) && key[:len(prefix)] == prefix) { + services = append(services, ep) + } + } + return &pb.ListServicesResponse{Services: services}, nil +} + +func (s *mockServiceServer) DeleteService(_ context.Context, req *pb.DeleteServiceRequest) (*pb.DeleteServiceResponse, error) { + s.mu.Lock() + defer s.mu.Unlock() + if s.deleteErr != nil { + return nil, s.deleteErr + } + + key := serviceKey(req.GetSandbox(), req.GetService()) + _, ok := s.endpoints[key] + if !ok { + return nil, status.Errorf(codes.NotFound, "service %q not found in sandbox %q", req.GetService(), req.GetSandbox()) + } + delete(s.endpoints, key) + return &pb.DeleteServiceResponse{Deleted: true}, nil +} + +// --- Test setup --- + +func setupServiceTest(t *testing.T, mock *mockServiceServer) (*serviceClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newServiceClient(conn), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestServiceExpose(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, true) + + require.NoError(t, err) + require.NotNil(t, ep) + assert.Equal(t, "ep-api", ep.ID) + assert.Equal(t, "web-app", ep.SandboxName) + assert.Equal(t, "api", ep.ServiceName) + assert.Equal(t, uint32(8080), ep.TargetPort) + assert.True(t, ep.Domain) + assert.Equal(t, "https://api.example.com", ep.URL) +} + +func TestServiceExpose_NoDomain(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, false) + + require.NoError(t, err) + require.NotNil(t, ep) + assert.False(t, ep.Domain) + assert.Empty(t, ep.URL) +} + +func TestServiceExpose_Error(t *testing.T) { + mock := newMockServiceServer() + mock.exposeErr = status.Errorf(codes.NotFound, "sandbox not found") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Expose(context.Background(), "default", "missing", "api", 8080, true) + + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceGet(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // First expose, then get + _, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, true) + require.NoError(t, err) + + ep, err := client.Get(context.Background(), "default", "web-app", "api") + + require.NoError(t, err) + require.NotNil(t, ep) + assert.Equal(t, "api", ep.ServiceName) + assert.Equal(t, "web-app", ep.SandboxName) +} + +func TestServiceGet_NotFound(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Get(context.Background(), "default", "web-app", "nonexistent") + + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceGet_Error(t *testing.T) { + mock := newMockServiceServer() + mock.getErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + ep, err := client.Get(context.Background(), "default", "web-app", "api") + + assert.Nil(t, ep) + require.Error(t, err) +} + +func TestServiceList(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // Expose two services + _, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, true) + require.NoError(t, err) + _, err = client.Expose(context.Background(), "default", "web-app", "web", 3000, false) + require.NoError(t, err) + + endpoints, err := client.List(context.Background(), "default", "web-app") + + require.NoError(t, err) + assert.Len(t, endpoints, 2) +} + +func TestServiceList_Empty(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + endpoints, err := client.List(context.Background(), "default", "web-app") + + require.NoError(t, err) + assert.Empty(t, endpoints) +} + +func TestServiceList_WithOptions(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + _, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, true) + require.NoError(t, err) + + endpoints, err := client.List(context.Background(), "default", "web-app", ListOptions{Limit: 10, Offset: 0}) + + require.NoError(t, err) + assert.Len(t, endpoints, 1) +} + +func TestServiceList_Error(t *testing.T) { + mock := newMockServiceServer() + mock.listErr = status.Errorf(codes.Unavailable, "unavailable") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + endpoints, err := client.List(context.Background(), "default", "web-app") + + assert.Nil(t, endpoints) + require.Error(t, err) + assert.True(t, IsUnavailable(err)) +} + +func TestServiceDelete(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + // Expose then delete + _, err := client.Expose(context.Background(), "default", "web-app", "api", 8080, true) + require.NoError(t, err) + + err = client.Delete(context.Background(), "default", "web-app", "api") + + require.NoError(t, err) + + // Verify subsequent Get returns NotFound + ep, err := client.Get(context.Background(), "default", "web-app", "api") + assert.Nil(t, ep) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceDelete_NotFound(t *testing.T) { + mock := newMockServiceServer() + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "default", "web-app", "nonexistent") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestServiceDelete_Error(t *testing.T) { + mock := newMockServiceServer() + mock.deleteErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupServiceTest(t, mock) + defer cleanup() + + err := client.Delete(context.Background(), "default", "web-app", "api") + + require.Error(t, err) +} diff --git a/sdk/go/openshell/v1/ssh.go b/sdk/go/openshell/v1/ssh.go index de8d29b66b..19a4b65a3e 100644 --- a/sdk/go/openshell/v1/ssh.go +++ b/sdk/go/openshell/v1/ssh.go @@ -31,28 +31,7 @@ func WithTunnelServiceID(id string) TunnelOption { // SSHInterface defines operations for managing SSH sessions. type SSHInterface interface { - // CreateSession creates a new SSH session for the given sandbox. - // The returned SSHSession contains connection details including the - // sensitive Token field that must not be logged. - // - // Note: CreateSession accepts a raw sandbox ID, not a name. - // For name-based access with automatic session lifecycle management, - // prefer [SSHInterface.Tunnel] which resolves sandbox names internally - // and revokes the session on Close. CreateSession(ctx context.Context, workspace, sandboxID string) (*SSHSession, error) - // RevokeSession revokes an existing SSH session by its token. - // Returns true if the session was actively revoked, false if it was - // already expired or not found. RevokeSession(ctx context.Context, workspace, token string) (bool, error) - // Tunnel opens a bidirectional SSH tunnel to the given port inside a - // sandbox. It combines CreateSession and ForwardTcp(SshRelayTarget) - // into a single call with automatic session cleanup on Close. - // - // The sandboxName is resolved to a sandbox ID internally. Port must - // be in the range 1-65535. - // - // Errors: InvalidArgument if port is out of range or sandboxName is - // empty; NotFound if the sandbox does not exist; Unimplemented by - // the fake client; Unavailable if the client is closed. Tunnel(ctx context.Context, workspace, sandboxName string, port uint32, opts ...TunnelOption) (io.ReadWriteCloser, error) } diff --git a/sdk/go/openshell/v1/ssh_client.go b/sdk/go/openshell/v1/ssh_client.go new file mode 100644 index 0000000000..f2120e2cea --- /dev/null +++ b/sdk/go/openshell/v1/ssh_client.go @@ -0,0 +1,161 @@ +// 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" + 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, _, sandboxID string) (*SSHSession, error) { + resp, err := s.client.CreateSshSession(ctx, &pb.CreateSshSessionRequest{ + SandboxId: sandboxID, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.SSHSessionFromProto(resp), nil +} + +func (s *sshClient) RevokeSession(ctx context.Context, _, token string) (bool, error) { + resp, err := s.client.RevokeSshSession(ctx, &pb.RevokeSshSessionRequest{ + Token: token, + }) + if err != nil { + return false, converter.FromGRPCError(err) + } + return resp.GetRevoked(), 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), + } + } + + var cfg tunnelConfig + for _, o := range opts { + o(&cfg) + } + + sandbox, err := s.sandboxes.Get(ctx, workspace, sandboxName) + if err != nil { + return nil, err + } + + session, err := s.CreateSession(ctx, workspace, sandbox.ID) + 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{ + SandboxId: sandbox.ID, + 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) +} + +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 +} diff --git a/sdk/go/openshell/v1/ssh_client_test.go b/sdk/go/openshell/v1/ssh_client_test.go new file mode 100644 index 0000000000..d45cd7e4b7 --- /dev/null +++ b/sdk/go/openshell/v1/ssh_client_test.go @@ -0,0 +1,612 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "net" + "sync" + "testing" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +// --- Mock server for SSH sessions --- + +type mockSSHServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + sessions map[string]*pb.CreateSshSessionResponse // key: sandbox ID + tokens map[string]bool // track active tokens + revokeCount int // total revocation attempts + revokeHasDeadline bool + createErr error + revokeErr error + forwardErr error + nextToken string // override token for testing + lastInit *pb.TcpForwardInit +} + +func newMockSSHServer() *mockSSHServer { + return &mockSSHServer{ + sessions: make(map[string]*pb.CreateSshSessionResponse), + tokens: make(map[string]bool), + } +} + +func (s *mockSSHServer) CreateSshSession(_ context.Context, req *pb.CreateSshSessionRequest) (*pb.CreateSshSessionResponse, error) { //nolint:revive // proto-generated method name + s.mu.Lock() + defer s.mu.Unlock() + if s.createErr != nil { + return nil, s.createErr + } + + token := "tok-" + req.GetSandboxId() + if s.nextToken != "" { + token = s.nextToken + } + + resp := &pb.CreateSshSessionResponse{ + SandboxId: req.GetSandboxId(), + Token: token, + GatewayHost: "gw.example.com", + GatewayPort: 2222, + GatewayScheme: "https", + HostKeyFingerprint: "SHA256:abc123", + ExpiresAtMs: 1700000000000, + } + s.sessions[req.GetSandboxId()] = resp + s.tokens[token] = true + return resp, nil +} + +func (s *mockSSHServer) RevokeSshSession(ctx context.Context, req *pb.RevokeSshSessionRequest) (*pb.RevokeSshSessionResponse, error) { //nolint:revive // proto-generated method name + s.mu.Lock() + defer s.mu.Unlock() + s.revokeCount++ + _, s.revokeHasDeadline = ctx.Deadline() + if s.revokeErr != nil { + return nil, s.revokeErr + } + + token := req.GetToken() + active, exists := s.tokens[token] + if exists && active { + s.tokens[token] = false + return &pb.RevokeSshSessionResponse{Revoked: true}, nil + } + // Already revoked or not found — not an error, just revoked=false. + return &pb.RevokeSshSessionResponse{Revoked: false}, nil +} + +func (s *mockSSHServer) ForwardTcp(stream grpc.BidiStreamingServer[pb.TcpForwardFrame, pb.TcpForwardFrame]) error { //nolint:revive // proto-generated method name + s.mu.Lock() + earlyErr := s.forwardErr + s.mu.Unlock() + if earlyErr != nil { + return earlyErr + } + + frame, err := stream.Recv() + if err != nil { + return err + } + init := frame.GetInit() + if init == nil { + return status.Errorf(codes.InvalidArgument, "first frame must be init") + } + + s.mu.Lock() + s.lastInit = init + s.mu.Unlock() + + for { + frame, err = stream.Recv() + if err != nil { + return err + } + data := frame.GetData() + if data == nil { + continue + } + if err := stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: data}, + }); err != nil { + return err + } + } +} + +// --- Mock sandbox resolver --- + +type mockSandboxResolver struct { + sandboxes map[string]*Sandbox + err error +} + +func (m *mockSandboxResolver) Create(_ context.Context, _, _ string, _ *SandboxSpec, _ map[string]string, _ ...CreateOptions) (*Sandbox, error) { + return nil, nil +} + +func (m *mockSandboxResolver) Get(_ context.Context, _, name string) (*Sandbox, error) { + if m.err != nil { + return nil, m.err + } + sb, ok := m.sandboxes[name] + if !ok { + return nil, &StatusError{Code: ErrorNotFound, Message: "sandbox not found: " + name} + } + return sb, nil +} + +func (m *mockSandboxResolver) List(_ context.Context, _ string, _ ...ListOptions) ([]*Sandbox, error) { + return nil, nil +} +func (m *mockSandboxResolver) Delete(_ context.Context, _, _ string) error { return nil } +func (m *mockSandboxResolver) AttachProvider(_ context.Context, _, _, _ string, _ uint64) (*AttachProviderResult, error) { + return nil, nil +} +func (m *mockSandboxResolver) DetachProvider(_ context.Context, _, _, _ string, _ uint64) (*DetachProviderResult, error) { + return nil, nil +} +func (m *mockSandboxResolver) ListProviders(_ context.Context, _, _ string) ([]*Provider, error) { + return nil, nil +} +func (m *mockSandboxResolver) WaitReady(_ context.Context, _, _ string, _ ...WaitOptions) (*Sandbox, error) { + return nil, nil +} +func (m *mockSandboxResolver) Watch(_ context.Context, _, _ string, _ ...WatchOptions) (WatchInterface[*Sandbox], error) { + return nil, nil +} +func (m *mockSandboxResolver) GetLogs(_ context.Context, _, _ string, _ ...LogOption) (*LogResult, error) { + return nil, nil +} + +// --- Test setup --- + +func setupSSHTest(t *testing.T, mock *mockSSHServer) (*sshClient, func()) { + t.Helper() + return setupSSHTestWithSandboxes(t, mock, nil) +} + +func setupSSHTestWithSandboxes(t *testing.T, mock *mockSSHServer, sandboxes SandboxInterface) (*sshClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newSSHClient(conn, sandboxes), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestSSHCreateSession(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + session, err := client.CreateSession(context.Background(), "default", "my-sandbox") + + require.NoError(t, err) + require.NotNil(t, session) + assert.Equal(t, "my-sandbox", session.SandboxID) + assert.Equal(t, "tok-my-sandbox", session.Token) + assert.Equal(t, "gw.example.com", session.GatewayHost) + assert.Equal(t, uint32(2222), session.GatewayPort) + assert.Equal(t, "https", session.GatewayScheme) + assert.Equal(t, "SHA256:abc123", session.HostKeyFingerprint) + assert.Equal(t, int64(1700000000000), session.ExpiresAtMs) +} + +func TestSSHCreateSession_Error(t *testing.T) { + mock := newMockSSHServer() + mock.createErr = status.Errorf(codes.NotFound, "sandbox not found") + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + session, err := client.CreateSession(context.Background(), "default", "missing") + + assert.Nil(t, session) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSSHRevokeSession(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + // Create a session first. + session, err := client.CreateSession(context.Background(), "default", "my-sandbox") + require.NoError(t, err) + + // Revoke it — should return true. + revoked, err := client.RevokeSession(context.Background(), "default", session.Token) + + require.NoError(t, err) + assert.True(t, revoked) +} + +func TestSSHRevokeSession_AlreadyRevoked(t *testing.T) { + mock := newMockSSHServer() + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + // Create and revoke. + session, err := client.CreateSession(context.Background(), "default", "my-sandbox") + require.NoError(t, err) + _, err = client.RevokeSession(context.Background(), "default", session.Token) + require.NoError(t, err) + + // Revoke again — should return false (already revoked). + revoked, err := client.RevokeSession(context.Background(), "default", session.Token) + + require.NoError(t, err) + assert.False(t, revoked) +} + +func TestSSHRevokeSession_Error(t *testing.T) { + mock := newMockSSHServer() + mock.revokeErr = status.Errorf(codes.Internal, "internal error") + client, cleanup := setupSSHTest(t, mock) + defer cleanup() + + revoked, err := client.RevokeSession(context.Background(), "default", "some-token") + + assert.False(t, revoked) + require.Error(t, err) + var se *StatusError + require.ErrorAs(t, err, &se) + assert.Equal(t, ErrorInternal, se.Code) +} + +// --- Tunnel tests (T012) --- + +func defaultSandboxResolver() *mockSandboxResolver { + return &mockSandboxResolver{ + sandboxes: map[string]*Sandbox{ + "my-sandbox": {ID: "sb-123", Name: "my-sandbox"}, + }, + } +} + +func TestSSHTunnel_Success(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "default", "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Round-trip to verify the stream works. + _, err = rwc.Write([]byte("hello")) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "hello", string(buf[:n])) + + // Verify init frame sent to server. + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "sb-123", init.GetSandboxId()) + assert.NotEmpty(t, init.GetAuthorizationToken()) + assert.NotNil(t, init.GetSsh(), "target should be SshRelayTarget") +} + +func TestSSHTunnel_WithServiceID(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "default", "my-sandbox", 22, WithTunnelServiceID("audit-svc")) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "audit-svc", init.GetServiceId()) +} + +func TestSSHTunnel_InvalidPort(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rwc, err := client.Tunnel(context.Background(), "default", "my-sandbox", tt.port) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) + }) + } +} + +func TestSSHTunnel_EmptySandboxName(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "default", "", 22) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestSSHTunnel_SandboxNotFound(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "default", "nonexistent", 22) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestSSHTunnel_SessionRevokedOnForwardFailure(t *testing.T) { + mock := newMockSSHServer() + mock.forwardErr = status.Errorf(codes.Internal, "forward failed") + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "default", "my-sandbox", 22) + + if err != nil { + assert.Nil(t, rwc) + } else { + require.NotNil(t, rwc) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + _ = rwc.Close() + } + + // Session should have been revoked since the forward failed. + mock.mu.Lock() + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + assert.True(t, tokenRevoked, "session token should be revoked after forward failure") +} + +func TestSSHTunnel_SessionRevokedOnClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "default", "my-sandbox", 22) + require.NoError(t, err) + + // Close the tunnel, which should revoke the session. + err = rwc.Close() + require.NoError(t, err) + + mock.mu.Lock() + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + assert.True(t, tokenRevoked, "session token should be revoked after tunnel close") + assert.True(t, mock.revokeHasDeadline, "cleanup revocation must have a deadline") +} + +func TestSSHTunnel_DoubleClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "default", "my-sandbox", 22) + require.NoError(t, err) + + err = rwc.Close() + require.NoError(t, err) + + // Second close should not panic or return a different error. + err = rwc.Close() + assert.NoError(t, err) +} + +func TestSSHTunnel_ContextCancellation(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "default", "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) + + _ = rwc.Close() +} + +func TestSSHTunnel_TokenNotExposed(t *testing.T) { + mock := newMockSSHServer() + mock.nextToken = "secret-tunnel-token-xyz" + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + rwc, err := client.Tunnel(context.Background(), "default", "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + repr := fmt.Sprintf("%v", rwc) + assert.NotContains(t, repr, "secret-tunnel-token-xyz", + "token must not leak through the returned value's string representation") +} + +func TestSSHTunnel_ContextCancelRevokesSession(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "default", "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + require.Eventually(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + for _, active := range mock.tokens { + if !active { + return true + } + } + return false + }, 5*time.Second, 10*time.Millisecond, "session token should be revoked after context cancel") +} + +func TestSSHTunnel_ContextCancelThenClose(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "default", "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + cancel() + + // Wait for the cleanup goroutine to complete its revocation. + require.Eventually(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + for _, active := range mock.tokens { + if !active { + return true + } + } + return false + }, 5*time.Second, 10*time.Millisecond) + + // Explicit Close() after the cleanup goroutine already ran. + err = rwc.Close() + assert.NoError(t, err) + + mock.mu.Lock() + count := mock.revokeCount + mock.mu.Unlock() + assert.Equal(t, 1, count, "exactly one revocation should occur (closeOnce idempotency)") +} + +func TestSSHTunnel_CloseBeforeContextCancel(t *testing.T) { + mock := newMockSSHServer() + resolver := defaultSandboxResolver() + client, cleanup := setupSSHTestWithSandboxes(t, mock, resolver) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Tunnel(ctx, "default", "my-sandbox", 22) + require.NoError(t, err) + require.NotNil(t, rwc) + + // Explicit Close() first (revokes the session). + err = rwc.Close() + require.NoError(t, err) + + // Cancel context after Close() already completed. + cancel() + + // Verify the cleanup goroutine does not trigger a second revocation. + require.Never(t, func() bool { + mock.mu.Lock() + defer mock.mu.Unlock() + return mock.revokeCount > 1 + }, 200*time.Millisecond, 10*time.Millisecond, "cleanup goroutine should not revoke again") + + mock.mu.Lock() + count := mock.revokeCount + tokenRevoked := false + for _, active := range mock.tokens { + if !active { + tokenRevoked = true + break + } + } + mock.mu.Unlock() + + assert.True(t, tokenRevoked, "session should be revoked") + assert.Equal(t, 1, count, "exactly one revocation should occur") +} diff --git a/sdk/go/openshell/v1/stub_clients.go b/sdk/go/openshell/v1/stub_clients.go deleted file mode 100644 index 1fb25a86ab..0000000000 --- a/sdk/go/openshell/v1/stub_clients.go +++ /dev/null @@ -1,195 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -package v1 - -import ( - "context" - "io" - "net" -) - -func stubError(method string) error { - return &StatusError{ - Code: ErrorUnimplemented, - Message: method + " not yet available - see https://github.com/NVIDIA/OpenShell/issues/2270", - } -} - -// stubExec implements ExecInterface as a placeholder. -type stubExec struct{} - -func (s *stubExec) Run(_ context.Context, _, _ string, _ []string, _ ...ExecOptions) (*ExecResult, error) { - return nil, stubError("Exec.Run") -} -func (s *stubExec) Stream(_ context.Context, _, _ string, _ []string, _ ...ExecOptions) (ExecStream, error) { - return nil, stubError("Exec.Stream") -} -func (s *stubExec) Interactive(_ context.Context, _, _ string, _ []string, _, _ uint32, _ ...ExecOptions) (InteractiveSession, error) { - return nil, stubError("Exec.Interactive") -} - -// stubFiles implements FileInterface as a placeholder. -type stubFiles struct{} - -func (s *stubFiles) Upload(_ context.Context, _, _, _, _ string) error { - return stubError("Files.Upload") -} -func (s *stubFiles) Download(_ context.Context, _, _, _, _ string) error { - return stubError("Files.Download") -} - -// stubHealth implements HealthInterface as a placeholder. -type stubHealth struct{} - -func (s *stubHealth) Check(_ context.Context) (*HealthResult, error) { - return nil, stubError("Health.Check") -} - -// stubProviders implements ProviderInterface as a placeholder. -type stubProviders struct{} - -func (s *stubProviders) Create(_ context.Context, _ string, _ *Provider) (*Provider, error) { - return nil, stubError("Providers.Create") -} -func (s *stubProviders) Get(_ context.Context, _, _ string) (*Provider, error) { - return nil, stubError("Providers.Get") -} -func (s *stubProviders) List(_ context.Context, _ string, _ ...ListOptions) ([]*Provider, error) { - return nil, stubError("Providers.List") -} -func (s *stubProviders) Update(_ context.Context, _ string, _ *Provider) (*Provider, error) { - return nil, stubError("Providers.Update") -} -func (s *stubProviders) Delete(_ context.Context, _, _ string) error { - return stubError("Providers.Delete") -} -func (s *stubProviders) Ensure(_ context.Context, _ string, _ *Provider) (*Provider, error) { - return nil, stubError("Providers.Ensure") -} -func (s *stubProviders) Profiles() ProfileInterface { return &stubProfiles{} } -func (s *stubProviders) Refresh() RefreshInterface { return &stubRefresh{} } - -// stubProfiles implements ProfileInterface as a placeholder. -type stubProfiles struct{} - -func (s *stubProfiles) List(_ context.Context, _ string, _ ...ListOptions) ([]*ProviderProfile, error) { - return nil, stubError("Profiles.List") -} -func (s *stubProfiles) Get(_ context.Context, _, _ string) (*ProviderProfile, error) { - return nil, stubError("Profiles.Get") -} -func (s *stubProfiles) Import(_ context.Context, _ string, _ []ProfileImportItem) (*ImportResult, error) { - return nil, stubError("Profiles.Import") -} -func (s *stubProfiles) Update(_ context.Context, _, _ string, _ uint64, _ ProfileImportItem) (*UpdateResult, error) { - return nil, stubError("Profiles.Update") -} -func (s *stubProfiles) Lint(_ context.Context, _ string, _ []ProfileImportItem) (*LintResult, error) { - return nil, stubError("Profiles.Lint") -} -func (s *stubProfiles) Delete(_ context.Context, _, _ string) (bool, error) { - return false, stubError("Profiles.Delete") -} - -// stubRefresh implements RefreshInterface as a placeholder. -type stubRefresh struct{} - -func (s *stubRefresh) GetStatus(_ context.Context, _, _, _ string) ([]*RefreshStatus, error) { - return nil, stubError("Refresh.GetStatus") -} -func (s *stubRefresh) Configure(_ context.Context, _ string, _ *RefreshConfig) (*RefreshStatus, error) { - return nil, stubError("Refresh.Configure") -} -func (s *stubRefresh) Rotate(_ context.Context, _, _, _ string) (*RefreshStatus, error) { - return nil, stubError("Refresh.Rotate") -} -func (s *stubRefresh) Delete(_ context.Context, _, _, _ string) (bool, error) { - return false, stubError("Refresh.Delete") -} - -// stubServices implements ServiceInterface as a placeholder. -type stubServices struct{} - -func (s *stubServices) Expose(_ context.Context, _, _, _ string, _ uint32, _ bool) (*ServiceEndpoint, error) { - return nil, stubError("Services.Expose") -} -func (s *stubServices) Get(_ context.Context, _, _, _ string) (*ServiceEndpoint, error) { - return nil, stubError("Services.Get") -} -func (s *stubServices) List(_ context.Context, _, _ string, _ ...ListOptions) ([]*ServiceEndpoint, error) { - return nil, stubError("Services.List") -} -func (s *stubServices) Delete(_ context.Context, _, _, _ string) error { - return stubError("Services.Delete") -} - -// stubSSH implements SSHInterface as a placeholder. -type stubSSH struct{} - -func (s *stubSSH) CreateSession(_ context.Context, _, _ string) (*SSHSession, error) { - return nil, stubError("SSH.CreateSession") -} -func (s *stubSSH) RevokeSession(_ context.Context, _, _ string) (bool, error) { - return false, stubError("SSH.RevokeSession") -} -func (s *stubSSH) Tunnel(_ context.Context, _, _ string, _ uint32, _ ...TunnelOption) (io.ReadWriteCloser, error) { - return nil, stubError("SSH.Tunnel") -} - -// stubTCP implements TCPInterface as a placeholder. -type stubTCP struct{} - -func (s *stubTCP) Forward(_ context.Context, _, _ string, _ uint32, _ ...ForwardOption) (io.ReadWriteCloser, error) { - return nil, stubError("TCP.Forward") -} -func (s *stubTCP) Listen(_ context.Context, _, _ string, _, _ uint32, _ ...ListenOption) (net.Listener, error) { - return nil, stubError("TCP.Listen") -} - -// stubConfig implements ConfigInterface as a placeholder. -type stubConfig struct{} - -func (s *stubConfig) GetSandbox(_ context.Context, _, _ string) (*SandboxConfig, error) { - return nil, stubError("Config.GetSandbox") -} -func (s *stubConfig) GetGateway(_ context.Context) (*GatewayConfig, error) { - return nil, stubError("Config.GetGateway") -} -func (s *stubConfig) Update(_ context.Context, _ string, _ *ConfigUpdate) (*ConfigUpdateResult, error) { - return nil, stubError("Config.Update") -} - -// stubPolicy implements PolicyInterface as a placeholder. -type stubPolicy struct{} - -func (s *stubPolicy) GetDraft(_ context.Context, _, _ string, _ ...GetDraftOption) (*DraftPolicy, error) { - return nil, stubError("Policy.GetDraft") -} -func (s *stubPolicy) ApproveDraftChunk(_ context.Context, _, _, _ string) (*ApproveResult, error) { - return nil, stubError("Policy.ApproveDraftChunk") -} -func (s *stubPolicy) RejectDraftChunk(_ context.Context, _, _, _, _ string) error { - return stubError("Policy.RejectDraftChunk") -} -func (s *stubPolicy) ApproveAllDraftChunks(_ context.Context, _, _ string, _ ...ApproveAllOption) (*ApproveAllResult, error) { - return nil, stubError("Policy.ApproveAllDraftChunks") -} -func (s *stubPolicy) ClearDraftChunks(_ context.Context, _, _ string) (*ClearResult, error) { - return nil, stubError("Policy.ClearDraftChunks") -} -func (s *stubPolicy) GetDraftHistory(_ context.Context, _, _ string) ([]DraftHistoryEntry, error) { - return nil, stubError("Policy.GetDraftHistory") -} -func (s *stubPolicy) GetStatus(_ context.Context, _, _ string, _ ...GetStatusOption) (*PolicyStatusResult, error) { - return nil, stubError("Policy.GetStatus") -} -func (s *stubPolicy) List(_ context.Context, _ string, _ ...ListPolicyOption) ([]SandboxPolicyRevision, error) { - return nil, stubError("Policy.List") -} -func (s *stubPolicy) EditDraftChunk(_ context.Context, _, _, _ string, _ *NetworkPolicyRule) error { - return stubError("Policy.EditDraftChunk") -} -func (s *stubPolicy) UndoDraftChunk(_ context.Context, _, _, _ string) (*UndoResult, error) { - return nil, stubError("Policy.UndoDraftChunk") -} diff --git a/sdk/go/openshell/v1/tcp.go b/sdk/go/openshell/v1/tcp.go index 950d2e206c..a8fcae133d 100644 --- a/sdk/go/openshell/v1/tcp.go +++ b/sdk/go/openshell/v1/tcp.go @@ -35,6 +35,14 @@ type listenConfig struct { // ListenOption configures a local listener opened via [TCPInterface.Listen]. type ListenOption func(*listenConfig) +// ForwardListener is the lifecycle handle for a local TCP forward. The SDK +// owns accepting and bridging local connections; callers dial Addr and call +// Close when the forwarding endpoint is no longer needed. +type ForwardListener interface { + Addr() net.Addr + Close() error +} + // WithBindAddress overrides the default local bind address ("127.0.0.1"). // Pass "0.0.0.0" to accept connections from any interface. func WithBindAddress(addr string) ListenOption { @@ -63,35 +71,6 @@ func WithListenServiceID(id string) ListenOption { // TCPInterface defines operations for TCP port forwarding to sandboxes. // Methods accept a sandbox name and resolve it to an ID internally. type TCPInterface interface { - // Forward opens a bidirectional TCP connection to the given port inside a - // sandbox. The sandbox is identified by name; the SDK resolves it to an - // ID internally. The returned io.ReadWriteCloser wraps the underlying - // gRPC stream; closing it terminates the stream. Port must be in the - // range 1-65535; out-of-range values are rejected client-side with an - // InvalidArgument error before opening the gRPC stream. - // - // The connection respects context cancellation: if ctx is cancelled, - // the stream is closed and pending Read/Write calls return a context error. Forward(ctx context.Context, workspace, sandboxName string, port uint32, opts ...ForwardOption) (io.ReadWriteCloser, error) - - // Listen binds a local TCP port and tunnels every accepted connection to - // the given port inside a sandbox, returning a standard [net.Listener]. - // Each call to Accept on the returned listener establishes a new tunnel - // to the sandbox port, bridging data bidirectionally. - // - // The sandbox is identified by name; the SDK resolves it to an ID - // internally. remotePort must be in the range 1-65535; localPort must be - // in the range 0-65535, where 0 lets the OS assign an ephemeral port - // (discoverable via Addr). - // - // Closing the listener stops accepting new connections, tears down all - // active tunnels, and blocks until all bridge goroutines finish. - // Cancelling ctx triggers the same shutdown behavior. - // - // Errors: - // - InvalidArgument: sandboxName is empty, remotePort is 0 or > 65535, - // or localPort is > 65535 - // - Unimplemented: returned by the fake client - // - Unavailable: client is closed - Listen(ctx context.Context, workspace, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (net.Listener, error) + Listen(ctx context.Context, workspace, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (ForwardListener, error) } diff --git a/sdk/go/openshell/v1/tcp_client.go b/sdk/go/openshell/v1/tcp_client.go new file mode 100644 index 0000000000..0945aff1cf --- /dev/null +++ b/sdk/go/openshell/v1/tcp_client.go @@ -0,0 +1,360 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "net" + "strconv" + "sync" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type tcpClient struct { + client pb.OpenShellClient + sandboxes SandboxInterface + ssh SSHInterface +} + +func newTCPClient(conn grpc.ClientConnInterface, sandboxes SandboxInterface, ssh SSHInterface) *tcpClient { + return &tcpClient{client: pb.NewOpenShellClient(conn), sandboxes: sandboxes, ssh: ssh} +} + +func (t *tcpClient) Forward(ctx context.Context, workspace, sandboxName string, port uint32, opts ...ForwardOption) (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), + } + } + + sb, err := t.sandboxes.Get(ctx, workspace, sandboxName) + if err != nil { + return nil, err + } + + var cfg forwardConfig + for _, o := range opts { + o(&cfg) + } + + streamCtx, cancel := context.WithCancel(ctx) + stream, err := t.client.ForwardTcp(streamCtx) + if err != nil { + cancel() + return nil, converter.FromGRPCError(err) + } + + initFrame := &pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Init{ + Init: &pb.TcpForwardInit{ + SandboxId: sb.ID, + ServiceId: cfg.serviceID, + Target: &pb.TcpForwardInit_Tcp{ + Tcp: &pb.TcpRelayTarget{ + Host: "127.0.0.1", + Port: port, + }, + }, + }, + }, + } + + 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() + return conn, nil +} + +func (t *tcpClient) Listen(ctx context.Context, workspace, sandboxName string, remotePort uint32, localPort uint32, opts ...ListenOption) (ForwardListener, error) { + if sandboxName == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "sandbox name must not be empty"} + } + if remotePort == 0 || remotePort > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("port must be in range 1-65535, got %d", remotePort), + } + } + if localPort > 65535 { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: fmt.Sprintf("local port must be in range 0-65535, got %d", localPort), + } + } + + cfg := listenConfig{bindAddress: "127.0.0.1"} + for _, o := range opts { + o(&cfg) + } + + if cfg.useSSHTunnel && t.ssh == nil { + return nil, &StatusError{ + Code: ErrorInvalidArgument, + Message: "WithSSHTunnel requires an SSH client, but none is available", + } + } + + addr := net.JoinHostPort(cfg.bindAddress, strconv.FormatUint(uint64(localPort), 10)) + inner, err := net.Listen("tcp", addr) + if err != nil { + return nil, fmt.Errorf("listen on %s: %w", addr, err) + } + + listenCtx, cancel := context.WithCancel(ctx) + tl := &tunnelListener{ + inner: inner, + ctx: listenCtx, + cancel: cancel, + tcp: t, + ssh: t.ssh, + workspace: workspace, + sandboxName: sandboxName, + remotePort: remotePort, + cfg: cfg, + } + + // Context-watcher: if the parent context is cancelled, close the listener. + go func() { + <-listenCtx.Done() + _ = tl.Close() + }() + + // The listener is a forwarding lifecycle handle: it owns acceptance and + // bridging. Callers only dial Addr() and close the handle when finished. + tl.wg.Add(1) + go tl.acceptLoop() + + return tl, nil +} + +func (tl *tunnelListener) acceptLoop() { + defer tl.wg.Done() + for { + if err := tl.acceptAndBridge(); err != nil { + return + } + } +} + +// tunnelListener implements net.Listener. It accepts local TCP connections +// and bridges each one to a sandbox port via Forward (or Tunnel in SSH mode). +type tunnelListener struct { + inner net.Listener + ctx context.Context + cancel context.CancelFunc + tcp *tcpClient + ssh SSHInterface + workspace string + sandboxName string + remotePort uint32 + cfg listenConfig + wg sync.WaitGroup + mu sync.Mutex + closing bool + closeOnce sync.Once + closeErr error +} + +func (tl *tunnelListener) acceptAndBridge() error { + for { + conn, err := tl.inner.Accept() + if err != nil { + return err + } + + // Establish the tunnel to the sandbox. + var tunnel io.ReadWriteCloser + if tl.cfg.useSSHTunnel && tl.ssh != nil { + var tunnelOpts []TunnelOption + if tl.cfg.serviceID != "" { + tunnelOpts = append(tunnelOpts, WithTunnelServiceID(tl.cfg.serviceID)) + } + tunnel, err = tl.ssh.Tunnel(tl.ctx, tl.workspace, tl.sandboxName, tl.remotePort, tunnelOpts...) + } else { + var fwdOpts []ForwardOption + if tl.cfg.serviceID != "" { + fwdOpts = append(fwdOpts, WithForwardServiceID(tl.cfg.serviceID)) + } + tunnel, err = tl.tcp.Forward(tl.ctx, tl.workspace, tl.sandboxName, tl.remotePort, fwdOpts...) + } + + if err != nil { + _ = conn.Close() + select { + case <-tl.ctx.Done(): + return tl.ctx.Err() + default: + continue + } + } + + tl.mu.Lock() + if tl.closing { + tl.mu.Unlock() + _ = conn.Close() + _ = tunnel.Close() + return net.ErrClosed + } + tl.wg.Add(1) + tl.mu.Unlock() + go tl.bridge(conn, tunnel) + + return nil + } +} + +// bridge copies data bidirectionally between the local connection and the +// tunnel. It runs in its own goroutine and decrements the WaitGroup on exit. +func (tl *tunnelListener) bridge(local net.Conn, tunnel io.ReadWriteCloser) { + defer tl.wg.Done() + defer func() { _ = local.Close() }() + defer func() { _ = tunnel.Close() }() + + done := make(chan struct{}, 2) + + // Local → tunnel + go func() { + _, _ = io.Copy(tunnel, local) + done <- struct{}{} + }() + + // Tunnel → local + go func() { + _, _ = io.Copy(local, tunnel) + done <- struct{}{} + }() + + <-done + _ = local.Close() + _ = tunnel.Close() + <-done +} + +// Close stops the listener from accepting new connections, cancels all +// active tunnels, and blocks until all bridge goroutines finish. +func (tl *tunnelListener) Close() error { + tl.closeOnce.Do(func() { + tl.mu.Lock() + tl.closing = true + tl.mu.Unlock() + tl.closeErr = tl.inner.Close() + tl.cancel() + tl.wg.Wait() + }) + return tl.closeErr +} + +// Addr returns the listener's network address (the bound local address). +func (tl *tunnelListener) Addr() net.Addr { + return tl.inner.Addr() +} + +// tcpForwardConn wraps a bidirectional TcpForwardFrame stream into an +// io.ReadWriteCloser. A background goroutine owns the Recv loop and routes +// data frames to dataCh. Read and Write may be called from different +// goroutines, but multiple concurrent Read callers are not supported. +type tcpForwardConn struct { + stream grpc.BidiStreamingClient[pb.TcpForwardFrame, pb.TcpForwardFrame] + streamCtx context.Context + cancel context.CancelFunc + sendMu sync.Mutex + dataCh chan []byte + done chan struct{} + errOnce sync.Once + err error + buf []byte +} + +func (c *tcpForwardConn) setErr(err error) { + c.errOnce.Do(func() { c.err = err }) +} + +func (c *tcpForwardConn) readLoop() { + defer close(c.dataCh) + defer close(c.done) + for { + frame, err := c.stream.Recv() + if err != nil { + if err != io.EOF { + c.setErr(converter.FromGRPCError(err)) + } + return + } + data := frame.GetData() + if data == nil { + continue + } + dataCopy := make([]byte, len(data)) + copy(dataCopy, data) + select { + case c.dataCh <- dataCopy: + case <-c.streamCtx.Done(): + return + } + } +} + +func (c *tcpForwardConn) Read(p []byte) (int, error) { + if len(p) == 0 { + return 0, nil + } + if len(c.buf) > 0 { + n := copy(p, c.buf) + c.buf = c.buf[n:] + return n, nil + } + + data, ok := <-c.dataCh + if !ok { + if c.err != nil { + return 0, c.err + } + return 0, io.EOF + } + n := copy(p, data) + if n < len(data) { + c.buf = append(c.buf, data[n:]...) + } + return n, nil +} + +func (c *tcpForwardConn) Write(p []byte) (int, error) { + c.sendMu.Lock() + defer c.sendMu.Unlock() + err := c.stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: p}, + }) + if err != nil { + return 0, converter.FromGRPCError(err) + } + return len(p), nil +} + +func (c *tcpForwardConn) Close() error { + c.sendMu.Lock() + err := c.stream.CloseSend() + c.sendMu.Unlock() + c.cancel() + <-c.done + return err +} diff --git a/sdk/go/openshell/v1/tcp_client_test.go b/sdk/go/openshell/v1/tcp_client_test.go new file mode 100644 index 0000000000..b6997a5f3e --- /dev/null +++ b/sdk/go/openshell/v1/tcp_client_test.go @@ -0,0 +1,1143 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "fmt" + "io" + "net" + "sync" + "testing" + "time" + + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +// --- Mock server for TCP forwarding --- + +// mockTCPServer implements the ForwardTcp bidi stream. It records the init +// frame and echoes every data frame back to the client. +type mockTCPServer struct { + pb.UnimplementedOpenShellServer + mu sync.Mutex + lastInit *pb.TcpForwardInit + err error // if non-nil, return this error immediately on stream open +} + +func newMockTCPServer() *mockTCPServer { + return &mockTCPServer{} +} + +func (s *mockTCPServer) ForwardTcp(stream grpc.BidiStreamingServer[pb.TcpForwardFrame, pb.TcpForwardFrame]) error { //nolint:revive // proto-generated method name + s.mu.Lock() + earlyErr := s.err + s.mu.Unlock() + if earlyErr != nil { + return earlyErr + } + + // First frame must be init. + frame, err := stream.Recv() + if err != nil { + return err + } + init := frame.GetInit() + if init == nil { + return status.Errorf(codes.InvalidArgument, "first frame must be init") + } + + s.mu.Lock() + s.lastInit = init + s.mu.Unlock() + + // Echo loop: every data frame is sent back verbatim. + for { + frame, err = stream.Recv() + if err != nil { + return err + } + data := frame.GetData() + if data == nil { + continue + } + if err := stream.Send(&pb.TcpForwardFrame{ + Payload: &pb.TcpForwardFrame_Data{Data: data}, + }); err != nil { + return err + } + } +} + +// --- Test setup --- + +func setupTCPTest(t *testing.T, mock *mockTCPServer) (*tcpClient, func()) { + t.Helper() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + + return newTCPClient(conn, &stubSandboxResolver{}, nil), func() { + _ = conn.Close() + srv.Stop() + } +} + +// --- Tests --- + +func TestTCPForward_InitFrame(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Write something to trigger the init frame to be sent (init is sent + // on Forward, before any Write — but we need a brief moment for the + // server to process it). + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + + // Read back the echo. + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "ping", string(buf[:n])) + + // Verify the init frame the server received. + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId()) + assert.Empty(t, init.GetServiceId(), "service_id should be empty per FR-007a") + assert.Empty(t, init.GetAuthorizationToken()) + + tcp := init.GetTcp() + require.NotNil(t, tcp, "target should be TcpRelayTarget") + assert.Equal(t, "127.0.0.1", tcp.GetHost()) + assert.Equal(t, uint32(8080), tcp.GetPort()) +} + +func TestTCPForward_ReadWrite(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "test-sandbox", 3000) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + // Write data and read the echo back. + payload := []byte("hello, sandbox!") + _, err = rwc.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) + + // Second round-trip. + _, err = rwc.Write([]byte("round2")) + require.NoError(t, err) + + n, err = rwc.Read(buf) + require.NoError(t, err) + assert.Equal(t, "round2", string(buf[:n])) +} + +func TestTCPForward_Close(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", 5432) + require.NoError(t, err) + + err = rwc.Close() + require.NoError(t, err) + + // Subsequent writes should fail. + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) + + // Subsequent reads should also fail. + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) +} + +func TestTCPForward_PartialRead(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", 8080) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + // Write a payload larger than the read buffer. + payload := []byte("abcdefghijklmnopqrstuvwxyz") + _, err = rwc.Write(payload) + require.NoError(t, err) + + // Read with a small buffer — should get partial data and buffer the rest. + var collected []byte + buf := make([]byte, 10) + for len(collected) < len(payload) { + n, readErr := rwc.Read(buf) + require.NoError(t, readErr) + collected = append(collected, buf[:n]...) + } + assert.Equal(t, payload, collected) +} + +func TestTCPForward_ConcurrentReadWrite(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", 8080) + require.NoError(t, err) + defer func() { _ = rwc.Close() }() + + const iterations = 50 + var wg sync.WaitGroup + errCh := make(chan error, 2) + wg.Add(2) + + go func() { + defer wg.Done() + for range iterations { + _, writeErr := rwc.Write([]byte("ping")) + if writeErr != nil { + errCh <- writeErr + return + } + } + }() + + go func() { + defer wg.Done() + buf := make([]byte, 64) + for range iterations { + _, readErr := rwc.Read(buf) + if readErr != nil { + errCh <- readErr + return + } + } + }() + + wg.Wait() + close(errCh) + for err := range errCh { + t.Fatalf("concurrent goroutine failed: %v", err) + } +} + +func TestTCPForward_PortValidation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + {"port way too high", 100000}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", tt.port) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "expected InvalidArgument, got: %v", err) + }) + } + + // Valid boundary ports should not get client-side rejection. + for _, port := range []uint32{1, 65535} { + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", port) + require.NoError(t, err, "port %d should be valid", port) + require.NotNil(t, rwc) + _ = rwc.Close() + } +} + +func TestTCPForward_ContextCancellation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + rwc, err := client.Forward(ctx, "default", "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + + // Cancel the context. + cancel() + + // Reads should return an error (context cancelled propagates through the gRPC stream). + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) + + // Writes should also fail after context cancellation. + _, err = rwc.Write([]byte("should fail")) + assert.Error(t, err) +} + +func TestTCPForward_WithServiceID(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", 8080, WithForwardServiceID("audit-svc")) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Trigger a round-trip so the server has processed the init frame. + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Equal(t, "audit-svc", init.GetServiceId()) + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId()) +} + +func TestTCPForward_WithoutOptions_BackwardCompat(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + assert.Empty(t, init.GetServiceId(), "service_id should be empty when no option provided") +} + +func TestTCPForward_ServerError(t *testing.T) { + mock := newMockTCPServer() + mock.err = status.Errorf(codes.Unavailable, "server unavailable") + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", 8080) + + // The stream opens successfully (gRPC bidi streams don't fail on open), + // but the first write or read should surface the server error. + if err != nil { + // When Send(initFrame) races with the server returning the error, + // the client may get the server status or a transport-level error. + assert.Nil(t, rwc) + require.Error(t, err) + return + } + + // If stream opened, the error surfaces on Read (the server returns it + // immediately, which closes the recv side). + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + buf := make([]byte, 64) + _, err = rwc.Read(buf) + assert.Error(t, err) +} + +// --- Name-to-ID resolution tests --- + +func TestTCPForward_ResolvesNameToID(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "my-sandbox", 8080) + require.NoError(t, err) + require.NotNil(t, rwc) + defer func() { _ = rwc.Close() }() + + // Trigger a round-trip so the server has processed the init frame. + _, err = rwc.Write([]byte("ping")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = rwc.Read(buf) + require.NoError(t, err) + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init) + // stubSandboxResolver returns ID "sb-" — verify the proto has the resolved ID, not the name + assert.Equal(t, "sb-my-sandbox", init.GetSandboxId(), "Forward should send resolved sandbox ID, not the name") +} + +func TestTCPForward_ResolutionError(t *testing.T) { + mock := newMockTCPServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + resolver := &stubSandboxResolver{ + getErr: &StatusError{Code: ErrorNotFound, Message: "sandbox not found"}, + } + client := newTCPClient(conn, resolver, nil) + + rwc, err := client.Forward(context.Background(), "default", "nonexistent", 8080) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestTCPForward_EmptySandboxName(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + rwc, err := client.Forward(context.Background(), "default", "", 8080) + assert.Nil(t, rwc) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +// --- Listen tests --- + +func TestTCPListen_ReturnsValidListener(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "default", "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Addr should return a non-nil TCP address with a non-zero port. + addr := ln.Addr() + require.NotNil(t, addr) + tcpAddr, ok := addr.(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr, got %T", addr) + assert.NotZero(t, tcpAddr.Port, "OS-assigned port should be non-zero") +} + +func TestTCPListen_ConcurrentConnections(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "default", "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + const numConns = 10 + var wg sync.WaitGroup + errCh := make(chan error, numConns) + + // Dial numConns goroutines, each independently writes and reads. + for i := range numConns { + wg.Add(1) + go func(idx int) { + defer wg.Done() + + conn, dialErr := net.Dial("tcp", ln.Addr().String()) + if dialErr != nil { + errCh <- fmt.Errorf("dial %d: %w", idx, dialErr) + return + } + defer func() { _ = conn.Close() }() + + payload := []byte(fmt.Sprintf("msg-%d", idx)) + _, writeErr := conn.Write(payload) + if writeErr != nil { + errCh <- fmt.Errorf("write %d: %w", idx, writeErr) + return + } + + buf := make([]byte, 256) + n, readErr := conn.Read(buf) + if readErr != nil { + errCh <- fmt.Errorf("read %d: %w", idx, readErr) + return + } + + if string(buf[:n]) != string(payload) { + errCh <- fmt.Errorf("conn %d: expected %q, got %q", idx, payload, buf[:n]) + } + }(i) + } + + wg.Wait() + close(errCh) + for err := range errCh { + t.Errorf("concurrent connection error: %v", err) + } +} + +func TestTCPListen_EphemeralPort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + // localPort=0 → OS assigns an ephemeral port. + ln, err := client.Listen(context.Background(), "default", "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Addr() should expose the assigned port. + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok) + assert.NotZero(t, tcpAddr.Port, "OS-assigned port should be non-zero") + + // Verify a connection through the ephemeral port actually works. + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + payload := []byte("ephemeral-test") + _, err = conn.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 256) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) +} + +func TestTCPListen_EmptySandboxName(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "default", "", 8080, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestTCPListen_BidirectionalDataFlow(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "default", "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + // Connect to the listener's local address. + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = conn.Close() }() + + // Write data through the local connection → tunnel → mock echo → back. + payload := []byte("hello through the tunnel") + _, err = conn.Write(payload) + require.NoError(t, err) + + // Read the echoed data back. + buf := make([]byte, 256) + n, err := conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload, buf[:n]) + + // Second round-trip to confirm bidirectionality. + payload2 := []byte("round two") + _, err = conn.Write(payload2) + require.NoError(t, err) + + n, err = conn.Read(buf) + require.NoError(t, err) + assert.Equal(t, payload2, buf[:n]) +} + +func TestTCPListen_InvalidRemotePort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + tests := []struct { + name string + port uint32 + }{ + {"port zero", 0}, + {"port too high", 65536}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ln, err := client.Listen(context.Background(), "default", "my-sandbox", tt.port, 0) + assert.Nil(t, ln) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "expected InvalidArgument, got: %v", err) + }) + } +} + +// --- Graceful shutdown tests --- + +func TestTCPListen_CloseTerminatesConnections(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "default", "my-sandbox", 8080, 0) + require.NoError(t, err) + + const numConns = 3 + conns := make([]net.Conn, numConns) + + // Establish 3 connections. + for i := range numConns { + conns[i], err = net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + // Verify data flows before shutdown. + _, err = conns[i].Write([]byte("pre-close")) + require.NoError(t, err) + buf := make([]byte, 256) + _, err = conns[i].Read(buf) + require.NoError(t, err) + } + + // Close the listener. Per SC-003, this should complete within 5 seconds. + closeDone := make(chan error, 1) + go func() { + closeDone <- ln.Close() + }() + + select { + case closeErr := <-closeDone: + assert.NoError(t, closeErr) + case <-time.After(5 * time.Second): + t.Fatal("Close did not complete within 5 seconds") + } + + // All connections should now return errors on read. + for i, conn := range conns { + buf := make([]byte, 64) + _, readErr := conn.Read(buf) + assert.Error(t, readErr, "connection %d should be closed after listener.Close()", i) + _ = conn.Close() + } +} + +func TestTCPListen_ContextCancellation(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ctx, cancel := context.WithCancel(context.Background()) + ln, err := client.Listen(ctx, "default", "my-sandbox", 8080, 0) + require.NoError(t, err) + require.NotNil(t, ln) + + conn, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + // Verify data flows before cancellation. + _, err = conn.Write([]byte("before-cancel")) + require.NoError(t, err) + buf := make([]byte, 256) + _, err = conn.Read(buf) + require.NoError(t, err) + + // Cancel the context — should trigger listener close. + cancel() + + // The connection should eventually fail. + // Give the context-watcher goroutine a moment to close the listener. + time.Sleep(50 * time.Millisecond) + + _, err = conn.Write([]byte("after-cancel")) + if err == nil { + // Write may succeed if buffered, but Read should fail. + buf = make([]byte, 64) + _, err = conn.Read(buf) + } + assert.Error(t, err, "connection should fail after context cancellation") + _ = conn.Close() +} + +func TestTCPListen_CloseIsIdempotent(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "default", "my-sandbox", 8080, 0) + require.NoError(t, err) + + // Close the listener immediately. + err = ln.Close() + require.NoError(t, err) + + assert.NoError(t, ln.Close()) +} + +// --- Custom bind address tests --- + +func TestTCPListen_WithBindAddress(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + // Verify WithBindAddress is accepted and the listener binds to the + // specified address. We use 127.0.0.1 explicitly since it is the only + // loopback address guaranteed on all platforms (macOS does not enable + // 127.0.0.2+ by default). The default-case assertion below confirms + // that omitting the option also produces 127.0.0.1. + ln, err := client.Listen( + context.Background(), "default", "my-sandbox", 8080, 0, + WithBindAddress("127.0.0.1"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr") + assert.Equal(t, "127.0.0.1", tcpAddr.IP.String(), + "listener should bind to the address specified by WithBindAddress") + + // Also verify that without WithBindAddress the default is 127.0.0.1. + lnDefault, err := client.Listen( + context.Background(), "default", "my-sandbox", 8080, 0, + ) + require.NoError(t, err) + defer func() { _ = lnDefault.Close() }() + + defaultAddr, ok := lnDefault.Addr().(*net.TCPAddr) + require.True(t, ok, "expected *net.TCPAddr") + assert.Equal(t, "127.0.0.1", defaultAddr.IP.String(), + "default bind address should be 127.0.0.1") +} + +// --- SSH tunnel transport tests --- + +// mockSSHClient implements SSHInterface for testing the SSH tunnel path. +type mockSSHClient struct { + mu sync.Mutex + tunnelCalls int +} + +func (m *mockSSHClient) CreateSession(_ context.Context, _, _ string) (*SSHSession, error) { + return nil, fmt.Errorf("not implemented in mock") +} + +func (m *mockSSHClient) RevokeSession(_ context.Context, _, _ string) (bool, error) { + return false, fmt.Errorf("not implemented in mock") +} + +// Tunnel returns a pipe that echoes data back, and increments the call counter. +func (m *mockSSHClient) Tunnel(_ context.Context, _, _ string, _ uint32, _ ...TunnelOption) (io.ReadWriteCloser, error) { + m.mu.Lock() + m.tunnelCalls++ + m.mu.Unlock() + + // Create a pipe-based echo tunnel: read from one end, write back to the other. + clientReader, serverWriter := io.Pipe() + serverReader, clientWriter := io.Pipe() + + // Echo goroutine: copy everything from server reader to server writer. + go func() { + buf := make([]byte, 4096) + for { + n, err := serverReader.Read(buf) + if err != nil { + _ = serverWriter.Close() + return + } + if _, wErr := serverWriter.Write(buf[:n]); wErr != nil { + return + } + } + }() + + return &pipeRWC{Reader: clientReader, Writer: clientWriter, closers: []io.Closer{clientReader, clientWriter, serverReader, serverWriter}}, nil +} + +// pipeRWC wraps a Reader and Writer into an io.ReadWriteCloser. +type pipeRWC struct { + io.Reader + io.Writer + closers []io.Closer +} + +func (p *pipeRWC) Close() error { + for _, c := range p.closers { + _ = c.Close() + } + return nil +} + +func TestTCPListen_WithSSHTunnel(t *testing.T) { + mock := newMockTCPServer() + + // Set up the gRPC connection (needed for tcpClient even though SSH path + // won't use Forward). + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + sshMock := &mockSSHClient{} + client := newTCPClient(conn, &stubSandboxResolver{}, sshMock) + + ln, err := client.Listen( + context.Background(), "default", "my-sandbox", 8080, 0, + WithSSHTunnel(), + WithListenServiceID("ssh-svc"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + // Connect and send data through the SSH tunnel path. + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + payload := []byte("ssh-tunnel-test") + _, err = c.Write(payload) + require.NoError(t, err) + + buf := make([]byte, 256) + n, err := c.Read(buf) + require.NoError(t, err) + assert.Equal(t, string(payload), string(buf[:n]), + "data should echo through SSH tunnel") + + // Verify that Tunnel was called (not Forward). + sshMock.mu.Lock() + calls := sshMock.tunnelCalls + sshMock.mu.Unlock() + assert.Equal(t, 1, calls, "SSH Tunnel should have been called exactly once") + + // Verify no Forward calls happened on the mock TCP server. + mock.mu.Lock() + initFrame := mock.lastInit + mock.mu.Unlock() + assert.Nil(t, initFrame, "TCP Forward should not have been called when using SSH tunnel") + + _ = c.Close() +} + +func TestTCPListen_CallerSpecifiedPort(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + const wantPort = 19876 + ln, err := client.Listen(context.Background(), "default", "my-sandbox", 8080, wantPort) + require.NoError(t, err) + require.NotNil(t, ln) + defer func() { _ = ln.Close() }() + + tcpAddr, ok := ln.Addr().(*net.TCPAddr) + require.True(t, ok) + assert.Equal(t, wantPort, tcpAddr.Port, "listener should bind to the exact port requested") + + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + defer func() { _ = c.Close() }() + + _, err = c.Write([]byte("fixed-port")) + require.NoError(t, err) + + buf := make([]byte, 64) + n, err := c.Read(buf) + require.NoError(t, err) + assert.Equal(t, "fixed-port", string(buf[:n])) +} + +func TestTCPListen_ServiceIDPropagated(t *testing.T) { + mock := newMockTCPServer() + client, cleanup := setupTCPTest(t, mock) + defer cleanup() + + ln, err := client.Listen(context.Background(), "default", "my-sandbox", 8080, 0, + WithListenServiceID("test-svc-id"), + ) + require.NoError(t, err) + defer func() { _ = ln.Close() }() + + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + + _, err = c.Write([]byte("svc-id-test")) + require.NoError(t, err) + buf := make([]byte, 64) + _, err = c.Read(buf) + require.NoError(t, err) + _ = c.Close() + + mock.mu.Lock() + init := mock.lastInit + mock.mu.Unlock() + + require.NotNil(t, init, "mock should have received the init frame") + assert.Equal(t, "test-svc-id", init.GetServiceId(), + "Listen should propagate service ID to the Forward init frame") +} + +func TestTCPListen_WithSSHTunnel_NilSSH(t *testing.T) { + mock := newMockTCPServer() + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + client := newTCPClient(conn, &stubSandboxResolver{}, nil) + _, err = client.Listen(context.Background(), "default", "my-sandbox", 8080, 0, WithSSHTunnel()) + require.Error(t, err) + assert.True(t, IsInvalidArgument(err), "WithSSHTunnel with nil SSH client should return InvalidArgument") +} + +// --- Failure injection helpers --- + +// flippableResolver extends stubSandboxResolver with a mutex-guarded error +// that can be toggled at runtime (set to nil to stop failing). +type flippableResolver struct { + mu sync.Mutex + failErr error +} + +func (r *flippableResolver) Get(_ context.Context, _, name string) (*Sandbox, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.failErr != nil { + return nil, r.failErr + } + return &Sandbox{ID: "sb-" + name, Name: name}, nil +} + +func (r *flippableResolver) Create(context.Context, string, string, *SandboxSpec, map[string]string, ...CreateOptions) (*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) List(context.Context, string, ...ListOptions) ([]*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) Delete(context.Context, string, string) error { + panic("not implemented") +} +func (r *flippableResolver) AttachProvider(context.Context, string, string, string, uint64) (*AttachProviderResult, error) { + panic("not implemented") +} +func (r *flippableResolver) DetachProvider(context.Context, string, string, string, uint64) (*DetachProviderResult, error) { + panic("not implemented") +} +func (r *flippableResolver) ListProviders(context.Context, string, string) ([]*Provider, error) { + panic("not implemented") +} +func (r *flippableResolver) WaitReady(context.Context, string, string, ...WaitOptions) (*Sandbox, error) { + panic("not implemented") +} +func (r *flippableResolver) Watch(context.Context, string, string, ...WatchOptions) (WatchInterface[*Sandbox], error) { + panic("not implemented") +} +func (r *flippableResolver) GetLogs(context.Context, string, string, ...LogOption) (*LogResult, error) { + panic("not implemented") +} + +// --- Failure injection tests --- + +func TestTCPListen_TunnelSetupRetry(t *testing.T) { + mock := newMockTCPServer() + + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + // Use a resolver that fails initially, then succeeds. + resolver := &flippableResolver{ + failErr: &StatusError{Code: ErrorUnavailable, Message: "sandbox unreachable"}, + } + client := newTCPClient(conn, resolver, nil) + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + inner, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + + tl := &tunnelListener{ + inner: inner, + ctx: ctx, + cancel: cancel, + tcp: client, + sandboxName: "my-sandbox", + remotePort: 8080, + cfg: listenConfig{bindAddress: "127.0.0.1"}, + } + + tl.wg.Add(1) + go tl.acceptLoop() + + // First connection triggers Forward which fails (resolver returns error). + c1, err := net.Dial("tcp", inner.Addr().String()) + require.NoError(t, err) + defer func() { _ = c1.Close() }() + + time.Sleep(50 * time.Millisecond) + + // Clear the error so the next Forward succeeds. + resolver.mu.Lock() + resolver.failErr = nil + resolver.mu.Unlock() + + // Second connection should succeed through the retry loop. + c2, err := net.Dial("tcp", inner.Addr().String()) + require.NoError(t, err) + defer func() { _ = c2.Close() }() + + _, err = c2.Write([]byte("retry-ok")) + require.NoError(t, err) + buf := make([]byte, 32) + n, err := c2.Read(buf) + require.NoError(t, err) + assert.Equal(t, "retry-ok", string(buf[:n])) + + _ = tl.Close() +} + +func TestTCPListen_TunnelFailureWithContextCancel(t *testing.T) { + mock := newMockTCPServer() + + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + require.NoError(t, err) + defer func() { + _ = conn.Close() + srv.Stop() + }() + + // Resolver always fails: Forward will error on every attempt. + resolver := &flippableResolver{ + failErr: &StatusError{Code: ErrorUnavailable, Message: "permanent failure"}, + } + client := newTCPClient(conn, resolver, nil) + + ctx, cancel := context.WithCancel(context.Background()) + + ln, err := client.Listen(ctx, "default", "my-sandbox", 8080, 0) + require.NoError(t, err) + + // Trigger a connection that will fail tunnel setup. + c, err := net.Dial("tcp", ln.Addr().String()) + require.NoError(t, err) + _ = c.Close() + + // Give the internal accept loop time to enter tunnel setup. + time.Sleep(50 * time.Millisecond) + + // Cancel context: the context-watcher goroutine in Listen() calls + // Close(), which closes the inner listener and stops the accept loop. + cancel() + require.Eventually(t, func() bool { + return ln.Close() == nil + }, 5*time.Second, 10*time.Millisecond) +} diff --git a/sdk/go/openshell/v1/types.go b/sdk/go/openshell/v1/types.go index 012811cabb..2a5ef97b60 100644 --- a/sdk/go/openshell/v1/types.go +++ b/sdk/go/openshell/v1/types.go @@ -41,6 +41,3 @@ const ( // TLSConfig holds TLS connection settings. type TLSConfig = types.TLSConfig - -// RetryPolicy configures automatic retry behavior for failed RPCs. -type RetryPolicy = types.RetryPolicy diff --git a/sdk/go/openshell/v1/types/config.go b/sdk/go/openshell/v1/types/config.go index 9657061ff9..23c4b0afdc 100644 --- a/sdk/go/openshell/v1/types/config.go +++ b/sdk/go/openshell/v1/types/config.go @@ -3,17 +3,9 @@ package types -import "time" - // Config holds all settings needed to create a Client. type Config struct { Address string TLS *TLSConfig Auth AuthProvider - // Timeout is reserved for future use. It is not yet applied. - Timeout time.Duration - // RetryPolicy is reserved for future use. It is not yet applied. - RetryPolicy *RetryPolicy - // Logger is reserved for future use. It is not yet applied. - Logger Logger } diff --git a/sdk/go/openshell/v1/types/errors.go b/sdk/go/openshell/v1/types/errors.go index 14d43cd752..f8981e1e33 100644 --- a/sdk/go/openshell/v1/types/errors.go +++ b/sdk/go/openshell/v1/types/errors.go @@ -117,7 +117,7 @@ func IsConflict(err error) bool { return hasCode(err, ErrorConflict) } -// IsUnauthenticated returns true if the error indicates missing or invalid credentials. +// IsUnauthenticated returns true if the error indicates invalid or missing credentials. func IsUnauthenticated(err error) bool { return hasCode(err, ErrorUnauthenticated) } diff --git a/sdk/go/openshell/v1/types/health.go b/sdk/go/openshell/v1/types/health.go index 0036183180..1db3ec3872 100644 --- a/sdk/go/openshell/v1/types/health.go +++ b/sdk/go/openshell/v1/types/health.go @@ -8,3 +8,37 @@ type HealthResult struct { Healthy bool Version string } + +// ServiceStatus describes the health state of the gateway. +type ServiceStatus string + +// ServiceStatus constants. +const ( + ServiceStatusHealthy ServiceStatus = "Healthy" + ServiceStatusDegraded ServiceStatus = "Degraded" + ServiceStatusUnhealthy ServiceStatus = "Unhealthy" + ServiceStatusUnknown ServiceStatus = "Unknown" +) + +// GatewayInfo holds operational metadata about the gateway. +type GatewayInfo struct { + Status ServiceStatus + Version string + ComputeDrivers []ComputeDriverInfo +} + +// ComputeDriverInfo describes a compute backend available on the gateway. +type ComputeDriverInfo struct { + Name string + DriverName string + DriverVersion string +} + +// CurrentUser holds the authenticated caller's identity. +type CurrentUser struct { + Subject string + DisplayName string + Roles []string + Scopes []string + IdentityProvider string +} diff --git a/sdk/go/openshell/v1/types/inference.go b/sdk/go/openshell/v1/types/inference.go new file mode 100644 index 0000000000..945fd8a7f5 --- /dev/null +++ b/sdk/go/openshell/v1/types/inference.go @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +// InferenceRouteConfig holds parameters for setting an inference route. +// ProviderName and ModelID are required; the SDK validates them before +// sending the request to the gateway. +type InferenceRouteConfig struct { + // ProviderName is the provider record name for credentials and endpoint mapping. + ProviderName string + + // ModelID is the model identifier to force on generation calls. + ModelID string + + // RouteName is the route name to target. An empty string represents the + // default user-facing route. + RouteName string + + // NoVerify skips synchronous endpoint validation before persistence when true. + NoVerify bool + + // TimeoutSecs is the per-route request timeout in seconds. 0 means use the + // default (60s). + TimeoutSecs uint64 +} + +// InferenceRoute represents a configured inference route as returned by the +// gateway. For SetRoute responses, ValidationPerformed and ValidatedEndpoints +// contain verification metadata; for GetRoute responses they are zero-valued. +type InferenceRoute struct { + // ProviderName is the provider record name. + ProviderName string + + // ModelID is the model identifier. + ModelID string + + // Version is the server-assigned version for the route. + Version uint64 + + // RouteName is the route name that was configured or queried. + RouteName string + + // TimeoutSecs is the per-route request timeout in seconds. + TimeoutSecs uint64 + + // Workspace is the workspace the route belongs to. + Workspace string + + // ValidationPerformed indicates whether endpoint verification ran during + // this request. Only populated for SetRoute responses. + ValidationPerformed bool + + // ValidatedEndpoints lists endpoints probed during validation, if any. + // Only populated for SetRoute responses. + ValidatedEndpoints []ValidatedEndpoint +} + +// ValidatedEndpoint represents an endpoint that was probed during route +// validation. +type ValidatedEndpoint struct { + // URL is the endpoint URL that was validated. + URL string + + // Protocol is the protocol used (e.g., "openai", "vertex"). + Protocol string +} diff --git a/sdk/go/openshell/v1/types/network_policy.go b/sdk/go/openshell/v1/types/network_policy.go index 34920141d9..ed6938b9b8 100644 --- a/sdk/go/openshell/v1/types/network_policy.go +++ b/sdk/go/openshell/v1/types/network_policy.go @@ -38,14 +38,13 @@ type PolicyNetworkEndpoint struct { CredentialSigning string SigningService string SigningRegion string - JsonRpcMaxBodyBytes uint32 + JSONRPCMaxBodyBytes uint32 Mcp *McpOptions CredentialBinding *NetworkCredentialBinding } // NetworkCredentialBinding binds an endpoint to static credentials from an attached provider. type NetworkCredentialBinding struct { - // Provider is the attached provider whose static credentials may be resolved for the endpoint. Provider string } @@ -62,7 +61,7 @@ type L7Rule struct { Allow *L7Allow } -// L7Allow specifies layer-7 allow criteria for HTTP/GraphQL/MCP traffic. +// L7Allow specifies layer-7 allow criteria for HTTP/GraphQL traffic. type L7Allow struct { Method string Path string @@ -74,7 +73,7 @@ type L7Allow struct { Params map[string]L7QueryMatcher } -// L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL/MCP traffic. +// L7DenyRule specifies layer-7 deny criteria for HTTP/GraphQL traffic. type L7DenyRule struct { Method string Path string @@ -86,18 +85,18 @@ type L7DenyRule struct { Params map[string]L7QueryMatcher } -// McpOptions holds MCP-specific policy and inspection options. -type McpOptions struct { - StrictToolNames *bool - AllowAllKnownMcpMethods *bool -} - // L7QueryMatcher matches query parameters by glob pattern or exact values. type L7QueryMatcher struct { Glob string Any []string } +// McpOptions configures MCP-specific policy controls on a network endpoint. +type McpOptions struct { + StrictToolNames *bool + AllowAllKnownMcpMethods *bool +} + // GraphqlOperation describes a GraphQL operation for persisted-query validation. type GraphqlOperation struct { OperationType string diff --git a/sdk/go/openshell/v1/types/options.go b/sdk/go/openshell/v1/types/options.go index 4454b383be..cfd134b05e 100644 --- a/sdk/go/openshell/v1/types/options.go +++ b/sdk/go/openshell/v1/types/options.go @@ -6,10 +6,9 @@ package types import "time" // CreateOptions configures resource creation. -type CreateOptions struct{} - -// GetOptions configures resource retrieval. -type GetOptions struct{} +type CreateOptions struct { + Annotations map[string]string +} // ListOptions configures resource listing with pagination and filtering. type ListOptions struct { @@ -19,18 +18,8 @@ type ListOptions struct { AllWorkspaces bool } -// DeleteOptions configures resource deletion. -type DeleteOptions struct{} - -// UpdateOptions configures resource updates. -type UpdateOptions struct{} - // WatchOptions configures watch behavior. type WatchOptions struct { - // TimeoutSeconds is reserved for future use. Use context for timeout control. - TimeoutSeconds int64 - // LabelSelector is reserved for future use. - LabelSelector string // StopOnTerminal causes the watch to close automatically when the sandbox // reaches a terminal phase (Ready or Error). StopOnTerminal bool diff --git a/sdk/go/openshell/v1/types/policy.go b/sdk/go/openshell/v1/types/policy.go index f71aeca1dc..8713fce04c 100644 --- a/sdk/go/openshell/v1/types/policy.go +++ b/sdk/go/openshell/v1/types/policy.go @@ -110,6 +110,27 @@ type SandboxPolicy struct { // NetworkPolicies contains named network access rules. // Nil means no network policies are specified; an empty map is distinct from nil. NetworkPolicies map[string]NetworkPolicyRule + // NetworkMiddlewares contains named middleware pipeline configurations for + // network egress. Nil means no middleware is specified; an empty map is distinct from nil. + NetworkMiddlewares map[string]NetworkMiddlewareConfig +} + +// NetworkMiddlewareConfig configures a supervisor middleware pipeline for +// network egress. Middleware configs are referenced by name in the policy. +type NetworkMiddlewareConfig struct { + Name string + Middleware string + Config map[string]any + OnError string + Endpoints *MiddlewareEndpointSelector + Order int32 +} + +// MiddlewareEndpointSelector controls which admitted destinations use a +// middleware config, using host glob patterns. +type MiddlewareEndpointSelector struct { + Include []string + Exclude []string } // FilesystemPolicy controls which directories the sandbox can access @@ -155,6 +176,8 @@ type SandboxPolicyRevision struct { LoadedAt time.Time // Policy is the typed security policy for this revision. Nil when not requested or absent. Policy *SandboxPolicy + // Provenance is immutable metadata supplied with this policy revision. + Provenance map[string]string } // PolicyStatusResult contains the status of a sandbox's policy. @@ -272,6 +295,7 @@ func (c *approveAllConfig) IncludeSecurityFlagged() bool { // getStatusConfig holds configuration for GetStatus calls. type getStatusConfig struct { version uint32 + global bool } // GetStatusOption configures a GetStatus call. @@ -284,6 +308,15 @@ func WithVersion(version uint32) GetStatusOption { } } +// WithStatusGlobal enables global policy mode on GetStatus. When true, +// the query retrieves gateway-global policy status instead of sandbox-scoped +// status, and the sandbox name and workspace parameters are ignored. +func WithStatusGlobal(global bool) GetStatusOption { + return func(c *getStatusConfig) { + c.global = global + } +} + // ApplyGetStatusOptions applies options and returns the config. func ApplyGetStatusOptions(opts []GetStatusOption) getStatusConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package var cfg getStatusConfig @@ -298,10 +331,16 @@ func (c *getStatusConfig) Version() uint32 { return c.version } +// Global returns whether global policy mode is enabled. +func (c *getStatusConfig) Global() bool { + return c.global +} + // listPolicyConfig holds configuration for List calls. type listPolicyConfig struct { limit uint32 offset uint32 + global bool } // ListPolicyOption configures a List call. @@ -321,6 +360,15 @@ func WithOffset(offset uint32) ListPolicyOption { } } +// WithListGlobal enables global policy mode on List. When true, the query +// retrieves gateway-global policy revisions instead of sandbox-scoped ones, +// and the workspace parameter is ignored. +func WithListGlobal(global bool) ListPolicyOption { + return func(c *listPolicyConfig) { + c.global = global + } +} + // ApplyListPolicyOptions applies options and returns the config. func ApplyListPolicyOptions(opts []ListPolicyOption) listPolicyConfig { //nolint:revive // unexported return is intentional; consumed only by v1 package var cfg listPolicyConfig @@ -339,3 +387,8 @@ func (c *listPolicyConfig) Limit() uint32 { func (c *listPolicyConfig) Offset() uint32 { return c.offset } + +// Global returns whether global policy mode is enabled. +func (c *listPolicyConfig) Global() bool { + return c.global +} diff --git a/sdk/go/openshell/v1/types/profile.go b/sdk/go/openshell/v1/types/profile.go index 0f987335af..2ca7da18ff 100644 --- a/sdk/go/openshell/v1/types/profile.go +++ b/sdk/go/openshell/v1/types/profile.go @@ -30,16 +30,71 @@ type ProviderProfile struct { InferenceCapable bool Discovery ProfileDiscovery ResourceVersion uint64 + Annotations map[string]string + Source string + Scope string } // ProfileCredential defines a single credential required by a provider profile. type ProfileCredential struct { + Name string + Description string + EnvVars []string + Required bool + Secret bool + Refresh *ProfileCredentialRefresh + AuthStyle string + HeaderName string + QueryParam string + PathTemplate string + TokenGrant *CredentialTokenGrant +} + +// ProfileCredentialRefresh declares how a profile credential is refreshed. +type ProfileCredentialRefresh struct { + Strategy RefreshStrategy + TokenURL string + Scopes []string + RefreshBeforeSeconds int64 + MaxLifetimeSeconds int64 + Material []ProfileCredentialRefreshMaterial + AdditionalOutputs []ProfileCredentialRefreshOutput +} + +// ProfileCredentialRefreshMaterial declares one input required by a refresh strategy. +type ProfileCredentialRefreshMaterial struct { Name string Description string Required bool Secret bool } +// ProfileCredentialRefreshOutput maps a minted output to another credential. +type ProfileCredentialRefreshOutput struct { + Output string + Credential string +} + +// CredentialTokenGrant configures dynamic credential acquisition via OAuth2 grant. +type CredentialTokenGrant struct { + TokenEndpoint string + Audience string + JWTSVIDAudience string + Scopes []string + CacheTTLSeconds int64 + AudienceOverrides []TokenGrantAudienceOverride + ClientAssertionType string +} + +// TokenGrantAudienceOverride selects an endpoint-specific resource audience. +type TokenGrantAudienceOverride struct { + Host string + Port uint32 + Path string + Audience string + Scopes []string +} + // NetworkEndpoint describes a network endpoint provided by a profile. type NetworkEndpoint struct { Host string diff --git a/sdk/go/openshell/v1/types/sandbox.go b/sdk/go/openshell/v1/types/sandbox.go index 97bf723eb4..5851ff48d7 100644 --- a/sdk/go/openshell/v1/types/sandbox.go +++ b/sdk/go/openshell/v1/types/sandbox.go @@ -38,8 +38,8 @@ type SandboxTemplate struct { Labels map[string]string Annotations map[string]string Environment map[string]string - Resources map[string]any UserNamespaces *bool + Resources map[string]any DriverConfig map[string]any } diff --git a/sdk/go/openshell/v1/types/service.go b/sdk/go/openshell/v1/types/service.go index c25cb9b63d..fdf6dc425a 100644 --- a/sdk/go/openshell/v1/types/service.go +++ b/sdk/go/openshell/v1/types/service.go @@ -12,4 +12,5 @@ type ServiceEndpoint struct { TargetPort uint32 Domain bool URL string + Workspace string } diff --git a/sdk/go/openshell/v1/types/setting.go b/sdk/go/openshell/v1/types/setting.go index 005ff36c01..7dd8eff2f2 100644 --- a/sdk/go/openshell/v1/types/setting.go +++ b/sdk/go/openshell/v1/types/setting.go @@ -69,6 +69,9 @@ type SandboxConfig struct { GlobalPolicyVersion uint32 // ProviderEnvRevision is the fingerprint for provider credential inputs. ProviderEnvRevision uint64 + // PolicyValidationFailureMode is the gateway-configured posture for rejected + // policy generations ("fail_closed" or "retain_last_valid"). + PolicyValidationFailureMode string } // GatewayConfig represents gateway-global settings. @@ -99,6 +102,8 @@ type ConfigUpdate struct { MergeOperations []PolicyMergeOperation // ExpectedResourceVersion is for optimistic concurrency (0 = skip check). ExpectedResourceVersion uint64 + // Annotations is caller-provided metadata for sandbox-scoped updates. + Annotations map[string]string } // ConfigUpdateResult holds the result of a configuration update operation. @@ -112,4 +117,6 @@ type ConfigUpdateResult struct { SettingsRevision uint64 // Deleted is true when a setting delete removed an existing key. Deleted bool + // Annotations contains sandbox metadata annotations after the update. + Annotations map[string]string } diff --git a/sdk/go/openshell/v1/types/types.go b/sdk/go/openshell/v1/types/types.go index 01da4ba9ca..d2c1f259cc 100644 --- a/sdk/go/openshell/v1/types/types.go +++ b/sdk/go/openshell/v1/types/types.go @@ -3,8 +3,6 @@ package types -import "time" - // SandboxPhase represents the lifecycle phase of a sandbox. type SandboxPhase string @@ -42,13 +40,8 @@ type TLSConfig struct { CertFile string KeyFile string CAFile string - // Insecure skips TLS certificate verification. Use http:// for plaintext. + // Insecure disables TLS certificate verification. This makes the + // connection vulnerable to man-in-the-middle attacks. Only use for + // development gateways with self-signed certificates. Insecure bool } - -// RetryPolicy configures automatic retry behavior for failed RPCs. -type RetryPolicy struct { - MaxRetries int - InitialWait time.Duration - MaxWait time.Duration -} diff --git a/sdk/go/openshell/v1/types/workspace.go b/sdk/go/openshell/v1/types/workspace.go new file mode 100644 index 0000000000..4539dc5a04 --- /dev/null +++ b/sdk/go/openshell/v1/types/workspace.go @@ -0,0 +1,51 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package types + +import "time" + +// WorkspacePhase describes the lifecycle state of a workspace. +type WorkspacePhase string + +// WorkspacePhase constants. +const ( + WorkspaceActive WorkspacePhase = "Active" + WorkspaceTerminating WorkspacePhase = "Terminating" + WorkspaceUnknown WorkspacePhase = "Unknown" +) + +// WorkspaceRole describes a member's role within a workspace. +type WorkspaceRole string + +// WorkspaceRole constants. +const ( + WorkspaceRoleAdmin WorkspaceRole = "Admin" + WorkspaceRoleUser WorkspaceRole = "User" + WorkspaceRoleUnknown WorkspaceRole = "Unknown" +) + +// Workspace represents a logical grouping of resources. +type Workspace struct { + ID string + Name string + CreatedAt time.Time + Labels map[string]string + Annotations map[string]string + ResourceVersion uint64 + Workspace string + DeletionTimestamp *time.Time + Phase WorkspacePhase +} + +// WorkspaceMember represents a user's membership in a workspace. +type WorkspaceMember struct { + ID string + Name string + CreatedAt time.Time + Labels map[string]string + Annotations map[string]string + ResourceVersion uint64 + PrincipalSubject string + Role WorkspaceRole +} diff --git a/sdk/go/openshell/v1/watch_test.go b/sdk/go/openshell/v1/watch_test.go index 1d6d5dc07a..4f0c69ef0c 100644 --- a/sdk/go/openshell/v1/watch_test.go +++ b/sdk/go/openshell/v1/watch_test.go @@ -72,12 +72,15 @@ func TestWatcher_StopClosesChannel(t *testing.T) { } } -func TestWatcher_StopIsIdempotent(_ *testing.T) { +func TestWatcher_StopIsIdempotent(t *testing.T) { src := make(chan Event[string], 10) w := newTestWatcher(src) w.Stop() w.Stop() // must not panic + + _, ok := <-w.ResultChan() + assert.False(t, ok, "channel should remain closed after second Stop") } func TestWatcher_ErrorEvent(t *testing.T) { diff --git a/sdk/go/openshell/v1/workspace.go b/sdk/go/openshell/v1/workspace.go new file mode 100644 index 0000000000..15f46ef4e0 --- /dev/null +++ b/sdk/go/openshell/v1/workspace.go @@ -0,0 +1,47 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +// Workspace represents a logical grouping of resources. +type Workspace = types.Workspace + +// WorkspaceMember represents a user's membership in a workspace. +type WorkspaceMember = types.WorkspaceMember + +// WorkspacePhase describes the lifecycle state of a workspace. +type WorkspacePhase = types.WorkspacePhase + +// WorkspaceRole describes a member's role within a workspace. +type WorkspaceRole = types.WorkspaceRole + +// WorkspacePhase constants. +const ( + WorkspaceActive = types.WorkspaceActive + WorkspaceTerminating = types.WorkspaceTerminating + WorkspaceUnknown = types.WorkspaceUnknown +) + +// WorkspaceRole constants. +const ( + WorkspaceRoleAdmin = types.WorkspaceRoleAdmin + WorkspaceRoleUser = types.WorkspaceRoleUser + WorkspaceRoleUnknown = types.WorkspaceRoleUnknown +) + +// WorkspaceInterface defines workspace and member management operations. +type WorkspaceInterface interface { + Create(ctx context.Context, name string, labels map[string]string) (*Workspace, error) + Get(ctx context.Context, name string) (*Workspace, error) + List(ctx context.Context, opts ...ListOptions) ([]*Workspace, error) + Delete(ctx context.Context, name string) error + AddMember(ctx context.Context, workspace, principalSubject string, role WorkspaceRole) (*WorkspaceMember, error) + RemoveMember(ctx context.Context, workspace, principalSubject string) error + ListMembers(ctx context.Context, workspace string, opts ...ListOptions) ([]*WorkspaceMember, error) +} diff --git a/sdk/go/openshell/v1/workspace_client.go b/sdk/go/openshell/v1/workspace_client.go new file mode 100644 index 0000000000..b036217737 --- /dev/null +++ b/sdk/go/openshell/v1/workspace_client.go @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/internal/converter" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "google.golang.org/grpc" +) + +type workspaceClient struct { + client pb.OpenShellClient +} + +func newWorkspaceClient(conn grpc.ClientConnInterface) *workspaceClient { + return &workspaceClient{client: pb.NewOpenShellClient(conn)} +} + +func (w *workspaceClient) Create(ctx context.Context, name string, labels map[string]string) (*Workspace, error) { + if name == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + + resp, err := w.client.CreateWorkspace(ctx, &pb.CreateWorkspaceRequest{ + Name: name, + Labels: labels, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.WorkspaceFromProto(resp.GetWorkspace()), nil +} + +func (w *workspaceClient) Get(ctx context.Context, name string) (*Workspace, error) { + if name == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + + resp, err := w.client.GetWorkspace(ctx, &pb.GetWorkspaceRequest{ + Name: name, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.WorkspaceFromProto(resp.GetWorkspace()), nil +} + +func (w *workspaceClient) List(ctx context.Context, opts ...ListOptions) ([]*Workspace, error) { + req := &pb.ListWorkspacesRequest{} + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + req.LabelSelector = opts[0].LabelSelector + } + + resp, err := w.client.ListWorkspaces(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + workspaces := make([]*Workspace, 0, len(resp.GetWorkspaces())) + for _, proto := range resp.GetWorkspaces() { + workspaces = append(workspaces, converter.WorkspaceFromProto(proto)) + } + return workspaces, nil +} + +func (w *workspaceClient) Delete(ctx context.Context, name string) error { + if name == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + + _, err := w.client.DeleteWorkspace(ctx, &pb.DeleteWorkspaceRequest{ + Name: name, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (w *workspaceClient) AddMember(ctx context.Context, workspace, principalSubject string, role WorkspaceRole) (*WorkspaceMember, error) { + if workspace == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + if principalSubject == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "principal subject must not be empty"} + } + + protoRole := converter.WorkspaceRoleToProto(role) + if protoRole == pb.WorkspaceRole_WORKSPACE_ROLE_UNSPECIFIED { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "role must be Admin or User"} + } + + resp, err := w.client.AddWorkspaceMember(ctx, &pb.AddWorkspaceMemberRequest{ + Workspace: workspace, + PrincipalSubject: principalSubject, + Role: protoRole, + }) + if err != nil { + return nil, converter.FromGRPCError(err) + } + return converter.WorkspaceMemberFromProto(resp.GetMember()), nil +} + +func (w *workspaceClient) RemoveMember(ctx context.Context, workspace, principalSubject string) error { + if workspace == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + if principalSubject == "" { + return &StatusError{Code: ErrorInvalidArgument, Message: "principal subject must not be empty"} + } + + _, err := w.client.RemoveWorkspaceMember(ctx, &pb.RemoveWorkspaceMemberRequest{ + Workspace: workspace, + PrincipalSubject: principalSubject, + }) + if err != nil { + return converter.FromGRPCError(err) + } + return nil +} + +func (w *workspaceClient) ListMembers(ctx context.Context, workspace string, opts ...ListOptions) ([]*WorkspaceMember, error) { + if workspace == "" { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "workspace name must not be empty"} + } + + req := &pb.ListWorkspaceMembersRequest{ + Workspace: workspace, + } + if len(opts) > 0 { + if opts[0].Limit < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "limit must not be negative"} + } + if opts[0].Offset < 0 { + return nil, &StatusError{Code: ErrorInvalidArgument, Message: "offset must not be negative"} + } + req.Limit = uint32(opts[0].Limit) + req.Offset = uint32(opts[0].Offset) + } + + resp, err := w.client.ListWorkspaceMembers(ctx, req) + if err != nil { + return nil, converter.FromGRPCError(err) + } + + members := make([]*WorkspaceMember, 0, len(resp.GetMembers())) + for _, proto := range resp.GetMembers() { + members = append(members, converter.WorkspaceMemberFromProto(proto)) + } + return members, nil +} diff --git a/sdk/go/openshell/v1/workspace_test.go b/sdk/go/openshell/v1/workspace_test.go new file mode 100644 index 0000000000..f64a76e998 --- /dev/null +++ b/sdk/go/openshell/v1/workspace_test.go @@ -0,0 +1,468 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +package v1 + +import ( + "context" + "net" + "testing" + + dm "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + pb "github.com/NVIDIA/OpenShell/sdk/go/proto/openshellv1" + "github.com/stretchr/testify/assert" + "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" +) + +type mockWorkspaceServer struct { + pb.UnimplementedOpenShellServer + + createResp *pb.CreateWorkspaceResponse + getResp *pb.GetWorkspaceResponse + listResp *pb.ListWorkspacesResponse + deleteResp *pb.DeleteWorkspaceResponse + addMemberResp *pb.AddWorkspaceMemberResponse + removeMemberResp *pb.RemoveWorkspaceMemberResponse + listMembersResp *pb.ListWorkspaceMembersResponse + err error + lastCreateReq *pb.CreateWorkspaceRequest + lastListReq *pb.ListWorkspacesRequest + lastAddMemberReq *pb.AddWorkspaceMemberRequest + lastListMembersReq *pb.ListWorkspaceMembersRequest +} + +func (s *mockWorkspaceServer) CreateWorkspace(_ context.Context, req *pb.CreateWorkspaceRequest) (*pb.CreateWorkspaceResponse, error) { + s.lastCreateReq = req + if s.err != nil { + return nil, s.err + } + return s.createResp, nil +} + +func (s *mockWorkspaceServer) GetWorkspace(_ context.Context, _ *pb.GetWorkspaceRequest) (*pb.GetWorkspaceResponse, error) { + if s.err != nil { + return nil, s.err + } + return s.getResp, nil +} + +func (s *mockWorkspaceServer) ListWorkspaces(_ context.Context, req *pb.ListWorkspacesRequest) (*pb.ListWorkspacesResponse, error) { + s.lastListReq = req + if s.err != nil { + return nil, s.err + } + return s.listResp, nil +} + +func (s *mockWorkspaceServer) DeleteWorkspace(_ context.Context, _ *pb.DeleteWorkspaceRequest) (*pb.DeleteWorkspaceResponse, error) { + if s.err != nil { + return nil, s.err + } + return s.deleteResp, nil +} + +func (s *mockWorkspaceServer) AddWorkspaceMember(_ context.Context, req *pb.AddWorkspaceMemberRequest) (*pb.AddWorkspaceMemberResponse, error) { + s.lastAddMemberReq = req + if s.err != nil { + return nil, s.err + } + return s.addMemberResp, nil +} + +func (s *mockWorkspaceServer) RemoveWorkspaceMember(_ context.Context, _ *pb.RemoveWorkspaceMemberRequest) (*pb.RemoveWorkspaceMemberResponse, error) { + if s.err != nil { + return nil, s.err + } + return s.removeMemberResp, nil +} + +func (s *mockWorkspaceServer) ListWorkspaceMembers(_ context.Context, req *pb.ListWorkspaceMembersRequest) (*pb.ListWorkspaceMembersResponse, error) { + s.lastListMembersReq = req + if s.err != nil { + return nil, s.err + } + return s.listMembersResp, nil +} + +func newMockWorkspaceServer(mock *mockWorkspaceServer) (*grpc.ClientConn, func()) { + lis := bufconn.Listen(bufSize) + srv := grpc.NewServer() + pb.RegisterOpenShellServer(srv, mock) + + go func() { _ = srv.Serve(lis) }() + + conn, err := grpc.NewClient("passthrough:///bufconn", + grpc.WithContextDialer(func(_ context.Context, _ string) (net.Conn, error) { + return lis.Dial() + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + srv.Stop() + panic("grpc.NewClient failed: " + err.Error()) + } + + return conn, func() { + _ = conn.Close() + srv.Stop() + } +} + +func testWorkspace() *dm.Workspace { + return &dm.Workspace{ + Metadata: &dm.ObjectMeta{ + Id: "ws-1", + Name: "test-ws", + CreatedAtMs: 1700000000000, + Labels: map[string]string{"team": "platform"}, + ResourceVersion: 1, + }, + Status: &dm.WorkspaceStatus{ + Phase: dm.WorkspacePhase_WORKSPACE_PHASE_ACTIVE, + }, + } +} + +func TestWorkspaceCreate_Success(t *testing.T) { + mock := &mockWorkspaceServer{ + createResp: &pb.CreateWorkspaceResponse{Workspace: testWorkspace()}, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + ws, err := wc.Create(context.Background(), "test-ws", map[string]string{"team": "platform"}) + + require.NoError(t, err) + require.NotNil(t, ws) + assert.Equal(t, "test-ws", ws.Name) + assert.Equal(t, WorkspaceActive, ws.Phase) + assert.Equal(t, map[string]string{"team": "platform"}, ws.Labels) + assert.Equal(t, "test-ws", mock.lastCreateReq.GetName()) +} + +func TestWorkspaceCreate_EmptyName(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.Create(context.Background(), "", nil) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestWorkspaceCreate_AlreadyExists(t *testing.T) { + mock := &mockWorkspaceServer{ + err: status.Error(codes.AlreadyExists, "workspace already exists"), + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.Create(context.Background(), "existing-ws", nil) + + require.Error(t, err) + assert.True(t, IsAlreadyExists(err)) +} + +func TestWorkspaceGet_Success(t *testing.T) { + mock := &mockWorkspaceServer{ + getResp: &pb.GetWorkspaceResponse{Workspace: testWorkspace()}, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + ws, err := wc.Get(context.Background(), "test-ws") + + require.NoError(t, err) + require.NotNil(t, ws) + assert.Equal(t, "test-ws", ws.Name) +} + +func TestWorkspaceGet_EmptyName(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.Get(context.Background(), "") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestWorkspaceGet_NotFound(t *testing.T) { + mock := &mockWorkspaceServer{ + err: status.Error(codes.NotFound, "workspace not found"), + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.Get(context.Background(), "missing-ws") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestWorkspaceList_Success(t *testing.T) { + mock := &mockWorkspaceServer{ + listResp: &pb.ListWorkspacesResponse{ + Workspaces: []*dm.Workspace{testWorkspace()}, + }, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + workspaces, err := wc.List(context.Background()) + + require.NoError(t, err) + require.Len(t, workspaces, 1) + assert.Equal(t, "test-ws", workspaces[0].Name) +} + +func TestWorkspaceList_WithOptions(t *testing.T) { + mock := &mockWorkspaceServer{ + listResp: &pb.ListWorkspacesResponse{}, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.List(context.Background(), ListOptions{ + Limit: 10, + Offset: 5, + LabelSelector: "team=platform", + }) + + require.NoError(t, err) + assert.Equal(t, uint32(10), mock.lastListReq.GetLimit()) + assert.Equal(t, uint32(5), mock.lastListReq.GetOffset()) + assert.Equal(t, "team=platform", mock.lastListReq.GetLabelSelector()) +} + +func TestWorkspaceDelete_Success(t *testing.T) { + mock := &mockWorkspaceServer{ + deleteResp: &pb.DeleteWorkspaceResponse{Deleted: true}, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + err := wc.Delete(context.Background(), "test-ws") + + require.NoError(t, err) +} + +func TestWorkspaceDelete_EmptyName(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + err := wc.Delete(context.Background(), "") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestWorkspaceDelete_NotFound(t *testing.T) { + mock := &mockWorkspaceServer{ + err: status.Error(codes.NotFound, "workspace not found"), + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + err := wc.Delete(context.Background(), "missing-ws") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +// --- Member management tests --- + +func testMember() *pb.WorkspaceMember { + return &pb.WorkspaceMember{ + Metadata: &dm.ObjectMeta{ + Id: "mem-1", + Name: "member-auto", + CreatedAtMs: 1700000000000, + ResourceVersion: 1, + }, + PrincipalSubject: "user@example.com", + Role: pb.WorkspaceRole_WORKSPACE_ROLE_ADMIN, + } +} + +func TestAddMember_Success(t *testing.T) { + mock := &mockWorkspaceServer{ + addMemberResp: &pb.AddWorkspaceMemberResponse{Member: testMember()}, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + m, err := wc.AddMember(context.Background(), "test-ws", "user@example.com", WorkspaceRoleAdmin) + + require.NoError(t, err) + require.NotNil(t, m) + assert.Equal(t, "user@example.com", m.PrincipalSubject) + assert.Equal(t, WorkspaceRoleAdmin, m.Role) + assert.Equal(t, "test-ws", mock.lastAddMemberReq.GetWorkspace()) + assert.Equal(t, "user@example.com", mock.lastAddMemberReq.GetPrincipalSubject()) +} + +func TestAddMember_EmptyWorkspace(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.AddMember(context.Background(), "", "user@example.com", WorkspaceRoleAdmin) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestAddMember_EmptySubject(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.AddMember(context.Background(), "test-ws", "", WorkspaceRoleAdmin) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestAddMember_InvalidRole(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.AddMember(context.Background(), "test-ws", "user@example.com", WorkspaceRole("invalid")) + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestAddMember_AlreadyExists(t *testing.T) { + mock := &mockWorkspaceServer{ + err: status.Error(codes.AlreadyExists, "member already exists"), + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.AddMember(context.Background(), "test-ws", "user@example.com", WorkspaceRoleUser) + + require.Error(t, err) + assert.True(t, IsAlreadyExists(err)) +} + +func TestRemoveMember_Success(t *testing.T) { + mock := &mockWorkspaceServer{ + removeMemberResp: &pb.RemoveWorkspaceMemberResponse{Removed: true}, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + err := wc.RemoveMember(context.Background(), "test-ws", "user@example.com") + + require.NoError(t, err) +} + +func TestRemoveMember_EmptyWorkspace(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + err := wc.RemoveMember(context.Background(), "", "user@example.com") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestRemoveMember_EmptySubject(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + err := wc.RemoveMember(context.Background(), "test-ws", "") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestRemoveMember_NotFound(t *testing.T) { + mock := &mockWorkspaceServer{ + err: status.Error(codes.NotFound, "member not found"), + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + err := wc.RemoveMember(context.Background(), "test-ws", "missing@example.com") + + require.Error(t, err) + assert.True(t, IsNotFound(err)) +} + +func TestListMembers_Success(t *testing.T) { + mock := &mockWorkspaceServer{ + listMembersResp: &pb.ListWorkspaceMembersResponse{ + Members: []*pb.WorkspaceMember{testMember()}, + }, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + members, err := wc.ListMembers(context.Background(), "test-ws") + + require.NoError(t, err) + require.Len(t, members, 1) + assert.Equal(t, "user@example.com", members[0].PrincipalSubject) +} + +func TestListMembers_EmptyWorkspace(t *testing.T) { + mock := &mockWorkspaceServer{} + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.ListMembers(context.Background(), "") + + require.Error(t, err) + assert.True(t, IsInvalidArgument(err)) +} + +func TestListMembers_WithOptions(t *testing.T) { + mock := &mockWorkspaceServer{ + listMembersResp: &pb.ListWorkspaceMembersResponse{}, + } + conn, cleanup := newMockWorkspaceServer(mock) + defer cleanup() + + wc := newWorkspaceClient(conn) + _, err := wc.ListMembers(context.Background(), "test-ws", ListOptions{Limit: 5, Offset: 2}) + + require.NoError(t, err) + assert.Equal(t, uint32(5), mock.lastListMembersReq.GetLimit()) + assert.Equal(t, uint32(2), mock.lastListMembersReq.GetOffset()) +} diff --git a/sdk/go/proto/inferencev1/inference.pb.go b/sdk/go/proto/inferencev1/inference.pb.go new file mode 100644 index 0000000000..decc6c4f39 --- /dev/null +++ b/sdk/go/proto/inferencev1/inference.pb.go @@ -0,0 +1,1018 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.36.11 +// protoc (unknown) +// source: inference.proto + +package inferencev1 + +import ( + datamodelv1 "github.com/NVIDIA/OpenShell/sdk/go/proto/datamodelv1" + _ "github.com/NVIDIA/OpenShell/sdk/go/proto/optionsv1" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + reflect "reflect" + sync "sync" + unsafe "unsafe" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Persisted inference route configuration. +// +// Only `provider_name` and `model_id` are stored; endpoint, protocols, +// credentials, and auth style are resolved from the provider at bundle time. +type InferenceRouteConfig struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Provider record name backing this route. + ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + // Model identifier to force on generation calls. + ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + // Per-route request timeout in seconds. 0 means use default (60s). + TimeoutSecs uint64 `protobuf:"varint,3,opt,name=timeout_secs,json=timeoutSecs,proto3" json:"timeout_secs,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InferenceRouteConfig) Reset() { + *x = InferenceRouteConfig{} + mi := &file_inference_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InferenceRouteConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InferenceRouteConfig) ProtoMessage() {} + +func (x *InferenceRouteConfig) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[0] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InferenceRouteConfig.ProtoReflect.Descriptor instead. +func (*InferenceRouteConfig) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{0} +} + +func (x *InferenceRouteConfig) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *InferenceRouteConfig) GetModelId() string { + if x != nil { + return x.ModelId + } + return "" +} + +func (x *InferenceRouteConfig) GetTimeoutSecs() uint64 { + if x != nil { + return x.TimeoutSecs + } + return 0 +} + +// Storage envelope for a workspace-scoped inference route. +type InferenceRoute struct { + state protoimpl.MessageState `protogen:"open.v1"` + Metadata *datamodelv1.ObjectMeta `protobuf:"bytes,1,opt,name=metadata,proto3" json:"metadata,omitempty"` + Config *InferenceRouteConfig `protobuf:"bytes,2,opt,name=config,proto3" json:"config,omitempty"` + // Monotonic version incremented on every update. + Version uint64 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *InferenceRoute) Reset() { + *x = InferenceRoute{} + mi := &file_inference_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *InferenceRoute) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*InferenceRoute) ProtoMessage() {} + +func (x *InferenceRoute) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[1] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use InferenceRoute.ProtoReflect.Descriptor instead. +func (*InferenceRoute) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{1} +} + +func (x *InferenceRoute) GetMetadata() *datamodelv1.ObjectMeta { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *InferenceRoute) GetConfig() *InferenceRouteConfig { + if x != nil { + return x.Config + } + return nil +} + +func (x *InferenceRoute) GetVersion() uint64 { + if x != nil { + return x.Version + } + return 0 +} + +type SetInferenceRouteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Provider record name to use for credentials + endpoint mapping. + ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + // Model identifier to force on generation calls. + ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + // Route name to target. Empty string defaults to "inference.local" (user-facing). + // Use "sandbox-system" for the sandbox system-level inference route. + RouteName string `protobuf:"bytes,3,opt,name=route_name,json=routeName,proto3" json:"route_name,omitempty"` + // Verify the resolved upstream endpoint synchronously before persistence. + Verify bool `protobuf:"varint,4,opt,name=verify,proto3" json:"verify,omitempty"` + // Skip synchronous endpoint validation before persistence. + NoVerify bool `protobuf:"varint,5,opt,name=no_verify,json=noVerify,proto3" json:"no_verify,omitempty"` + // Per-route request timeout in seconds. 0 means use default (60s). + TimeoutSecs uint64 `protobuf:"varint,6,opt,name=timeout_secs,json=timeoutSecs,proto3" json:"timeout_secs,omitempty"` + // Target workspace. Empty string defaults to "default". + Workspace string `protobuf:"bytes,7,opt,name=workspace,proto3" json:"workspace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetInferenceRouteRequest) Reset() { + *x = SetInferenceRouteRequest{} + mi := &file_inference_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetInferenceRouteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetInferenceRouteRequest) ProtoMessage() {} + +func (x *SetInferenceRouteRequest) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[2] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetInferenceRouteRequest.ProtoReflect.Descriptor instead. +func (*SetInferenceRouteRequest) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{2} +} + +func (x *SetInferenceRouteRequest) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *SetInferenceRouteRequest) GetModelId() string { + if x != nil { + return x.ModelId + } + return "" +} + +func (x *SetInferenceRouteRequest) GetRouteName() string { + if x != nil { + return x.RouteName + } + return "" +} + +func (x *SetInferenceRouteRequest) GetVerify() bool { + if x != nil { + return x.Verify + } + return false +} + +func (x *SetInferenceRouteRequest) GetNoVerify() bool { + if x != nil { + return x.NoVerify + } + return false +} + +func (x *SetInferenceRouteRequest) GetTimeoutSecs() uint64 { + if x != nil { + return x.TimeoutSecs + } + return 0 +} + +func (x *SetInferenceRouteRequest) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +type ValidatedEndpoint struct { + state protoimpl.MessageState `protogen:"open.v1"` + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + Protocol string `protobuf:"bytes,2,opt,name=protocol,proto3" json:"protocol,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ValidatedEndpoint) Reset() { + *x = ValidatedEndpoint{} + mi := &file_inference_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ValidatedEndpoint) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ValidatedEndpoint) ProtoMessage() {} + +func (x *ValidatedEndpoint) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[3] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ValidatedEndpoint.ProtoReflect.Descriptor instead. +func (*ValidatedEndpoint) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{3} +} + +func (x *ValidatedEndpoint) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *ValidatedEndpoint) GetProtocol() string { + if x != nil { + return x.Protocol + } + return "" +} + +type SetInferenceRouteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + Version uint64 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` + // Route name that was configured. + RouteName string `protobuf:"bytes,4,opt,name=route_name,json=routeName,proto3" json:"route_name,omitempty"` + // Whether endpoint verification ran as part of this request. + ValidationPerformed bool `protobuf:"varint,5,opt,name=validation_performed,json=validationPerformed,proto3" json:"validation_performed,omitempty"` + // The concrete endpoints that were probed during validation, when available. + ValidatedEndpoints []*ValidatedEndpoint `protobuf:"bytes,6,rep,name=validated_endpoints,json=validatedEndpoints,proto3" json:"validated_endpoints,omitempty"` + // Per-route request timeout in seconds that was persisted. + TimeoutSecs uint64 `protobuf:"varint,7,opt,name=timeout_secs,json=timeoutSecs,proto3" json:"timeout_secs,omitempty"` + // Workspace the route was configured in. + Workspace string `protobuf:"bytes,8,opt,name=workspace,proto3" json:"workspace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetInferenceRouteResponse) Reset() { + *x = SetInferenceRouteResponse{} + mi := &file_inference_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetInferenceRouteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetInferenceRouteResponse) ProtoMessage() {} + +func (x *SetInferenceRouteResponse) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[4] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SetInferenceRouteResponse.ProtoReflect.Descriptor instead. +func (*SetInferenceRouteResponse) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{4} +} + +func (x *SetInferenceRouteResponse) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *SetInferenceRouteResponse) GetModelId() string { + if x != nil { + return x.ModelId + } + return "" +} + +func (x *SetInferenceRouteResponse) GetVersion() uint64 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *SetInferenceRouteResponse) GetRouteName() string { + if x != nil { + return x.RouteName + } + return "" +} + +func (x *SetInferenceRouteResponse) GetValidationPerformed() bool { + if x != nil { + return x.ValidationPerformed + } + return false +} + +func (x *SetInferenceRouteResponse) GetValidatedEndpoints() []*ValidatedEndpoint { + if x != nil { + return x.ValidatedEndpoints + } + return nil +} + +func (x *SetInferenceRouteResponse) GetTimeoutSecs() uint64 { + if x != nil { + return x.TimeoutSecs + } + return 0 +} + +func (x *SetInferenceRouteResponse) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +type GetInferenceRouteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Route name to query. Empty string defaults to "inference.local" (user-facing). + // Use "sandbox-system" for the sandbox system-level inference route. + RouteName string `protobuf:"bytes,1,opt,name=route_name,json=routeName,proto3" json:"route_name,omitempty"` + // Target workspace. Empty string defaults to "default". + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInferenceRouteRequest) Reset() { + *x = GetInferenceRouteRequest{} + mi := &file_inference_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInferenceRouteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInferenceRouteRequest) ProtoMessage() {} + +func (x *GetInferenceRouteRequest) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[5] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInferenceRouteRequest.ProtoReflect.Descriptor instead. +func (*GetInferenceRouteRequest) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{5} +} + +func (x *GetInferenceRouteRequest) GetRouteName() string { + if x != nil { + return x.RouteName + } + return "" +} + +func (x *GetInferenceRouteRequest) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +type GetInferenceRouteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + ProviderName string `protobuf:"bytes,1,opt,name=provider_name,json=providerName,proto3" json:"provider_name,omitempty"` + ModelId string `protobuf:"bytes,2,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + Version uint64 `protobuf:"varint,3,opt,name=version,proto3" json:"version,omitempty"` + // Route name that was queried. + RouteName string `protobuf:"bytes,4,opt,name=route_name,json=routeName,proto3" json:"route_name,omitempty"` + // Per-route request timeout in seconds. 0 means default (60s). + TimeoutSecs uint64 `protobuf:"varint,5,opt,name=timeout_secs,json=timeoutSecs,proto3" json:"timeout_secs,omitempty"` + // Workspace the route belongs to. + Workspace string `protobuf:"bytes,6,opt,name=workspace,proto3" json:"workspace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInferenceRouteResponse) Reset() { + *x = GetInferenceRouteResponse{} + mi := &file_inference_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInferenceRouteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInferenceRouteResponse) ProtoMessage() {} + +func (x *GetInferenceRouteResponse) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[6] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInferenceRouteResponse.ProtoReflect.Descriptor instead. +func (*GetInferenceRouteResponse) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{6} +} + +func (x *GetInferenceRouteResponse) GetProviderName() string { + if x != nil { + return x.ProviderName + } + return "" +} + +func (x *GetInferenceRouteResponse) GetModelId() string { + if x != nil { + return x.ModelId + } + return "" +} + +func (x *GetInferenceRouteResponse) GetVersion() uint64 { + if x != nil { + return x.Version + } + return 0 +} + +func (x *GetInferenceRouteResponse) GetRouteName() string { + if x != nil { + return x.RouteName + } + return "" +} + +func (x *GetInferenceRouteResponse) GetTimeoutSecs() uint64 { + if x != nil { + return x.TimeoutSecs + } + return 0 +} + +func (x *GetInferenceRouteResponse) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +type DeleteInferenceRouteRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Route name to delete. Empty string defaults to "inference.local" (user-facing). + // Use "sandbox-system" for the sandbox system-level inference route. + RouteName string `protobuf:"bytes,1,opt,name=route_name,json=routeName,proto3" json:"route_name,omitempty"` + // Target workspace. Empty string defaults to "default". + Workspace string `protobuf:"bytes,2,opt,name=workspace,proto3" json:"workspace,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteInferenceRouteRequest) Reset() { + *x = DeleteInferenceRouteRequest{} + mi := &file_inference_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteInferenceRouteRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteInferenceRouteRequest) ProtoMessage() {} + +func (x *DeleteInferenceRouteRequest) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[7] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteInferenceRouteRequest.ProtoReflect.Descriptor instead. +func (*DeleteInferenceRouteRequest) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{7} +} + +func (x *DeleteInferenceRouteRequest) GetRouteName() string { + if x != nil { + return x.RouteName + } + return "" +} + +func (x *DeleteInferenceRouteRequest) GetWorkspace() string { + if x != nil { + return x.Workspace + } + return "" +} + +type DeleteInferenceRouteResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + // Whether a route was actually deleted. + Deleted bool `protobuf:"varint,1,opt,name=deleted,proto3" json:"deleted,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *DeleteInferenceRouteResponse) Reset() { + *x = DeleteInferenceRouteResponse{} + mi := &file_inference_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *DeleteInferenceRouteResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteInferenceRouteResponse) ProtoMessage() {} + +func (x *DeleteInferenceRouteResponse) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[8] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteInferenceRouteResponse.ProtoReflect.Descriptor instead. +func (*DeleteInferenceRouteResponse) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{8} +} + +func (x *DeleteInferenceRouteResponse) GetDeleted() bool { + if x != nil { + return x.Deleted + } + return false +} + +type GetInferenceBundleRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInferenceBundleRequest) Reset() { + *x = GetInferenceBundleRequest{} + mi := &file_inference_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInferenceBundleRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInferenceBundleRequest) ProtoMessage() {} + +func (x *GetInferenceBundleRequest) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[9] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInferenceBundleRequest.ProtoReflect.Descriptor instead. +func (*GetInferenceBundleRequest) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{9} +} + +// A single resolved route ready for sandbox-local execution. +type ResolvedRoute struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + BaseUrl string `protobuf:"bytes,2,opt,name=base_url,json=baseUrl,proto3" json:"base_url,omitempty"` + Protocols []string `protobuf:"bytes,3,rep,name=protocols,proto3" json:"protocols,omitempty"` + ApiKey string `protobuf:"bytes,4,opt,name=api_key,json=apiKey,proto3" json:"api_key,omitempty"` + ModelId string `protobuf:"bytes,5,opt,name=model_id,json=modelId,proto3" json:"model_id,omitempty"` + ProviderType string `protobuf:"bytes,6,opt,name=provider_type,json=providerType,proto3" json:"provider_type,omitempty"` + // Per-route request timeout in seconds. 0 means use default (60s). + TimeoutSecs uint64 `protobuf:"varint,7,opt,name=timeout_secs,json=timeoutSecs,proto3" json:"timeout_secs,omitempty"` + // When true, the model identifier is embedded in the URL path (e.g. Vertex AI). + ModelInPath bool `protobuf:"varint,8,opt,name=model_in_path,json=modelInPath,proto3" json:"model_in_path,omitempty"` + // Optional override for the request path. When set, replaces the protocol-derived path. + // An empty string means POST directly to base_url/model_id with no additional path. + RequestPathOverride *string `protobuf:"bytes,9,opt,name=request_path_override,json=requestPathOverride,proto3,oneof" json:"request_path_override,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *ResolvedRoute) Reset() { + *x = ResolvedRoute{} + mi := &file_inference_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *ResolvedRoute) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ResolvedRoute) ProtoMessage() {} + +func (x *ResolvedRoute) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[10] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ResolvedRoute.ProtoReflect.Descriptor instead. +func (*ResolvedRoute) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{10} +} + +func (x *ResolvedRoute) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *ResolvedRoute) GetBaseUrl() string { + if x != nil { + return x.BaseUrl + } + return "" +} + +func (x *ResolvedRoute) GetProtocols() []string { + if x != nil { + return x.Protocols + } + return nil +} + +func (x *ResolvedRoute) GetApiKey() string { + if x != nil { + return x.ApiKey + } + return "" +} + +func (x *ResolvedRoute) GetModelId() string { + if x != nil { + return x.ModelId + } + return "" +} + +func (x *ResolvedRoute) GetProviderType() string { + if x != nil { + return x.ProviderType + } + return "" +} + +func (x *ResolvedRoute) GetTimeoutSecs() uint64 { + if x != nil { + return x.TimeoutSecs + } + return 0 +} + +func (x *ResolvedRoute) GetModelInPath() bool { + if x != nil { + return x.ModelInPath + } + return false +} + +func (x *ResolvedRoute) GetRequestPathOverride() string { + if x != nil && x.RequestPathOverride != nil { + return *x.RequestPathOverride + } + return "" +} + +type GetInferenceBundleResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Routes []*ResolvedRoute `protobuf:"bytes,1,rep,name=routes,proto3" json:"routes,omitempty"` + // Opaque revision tag for cache freshness checks. + Revision string `protobuf:"bytes,2,opt,name=revision,proto3" json:"revision,omitempty"` + // Timestamp (epoch ms) when this bundle was generated. + GeneratedAtMs int64 `protobuf:"varint,3,opt,name=generated_at_ms,json=generatedAtMs,proto3" json:"generated_at_ms,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *GetInferenceBundleResponse) Reset() { + *x = GetInferenceBundleResponse{} + mi := &file_inference_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *GetInferenceBundleResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetInferenceBundleResponse) ProtoMessage() {} + +func (x *GetInferenceBundleResponse) ProtoReflect() protoreflect.Message { + mi := &file_inference_proto_msgTypes[11] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetInferenceBundleResponse.ProtoReflect.Descriptor instead. +func (*GetInferenceBundleResponse) Descriptor() ([]byte, []int) { + return file_inference_proto_rawDescGZIP(), []int{11} +} + +func (x *GetInferenceBundleResponse) GetRoutes() []*ResolvedRoute { + if x != nil { + return x.Routes + } + return nil +} + +func (x *GetInferenceBundleResponse) GetRevision() string { + if x != nil { + return x.Revision + } + return "" +} + +func (x *GetInferenceBundleResponse) GetGeneratedAtMs() int64 { + if x != nil { + return x.GeneratedAtMs + } + return 0 +} + +var File_inference_proto protoreflect.FileDescriptor + +const file_inference_proto_rawDesc = "" + + "\n" + + "\x0finference.proto\x12\x16openshell.inference.v1\x1a\x0fdatamodel.proto\x1a\roptions.proto\"y\n" + + "\x14InferenceRouteConfig\x12#\n" + + "\rprovider_name\x18\x01 \x01(\tR\fproviderName\x12\x19\n" + + "\bmodel_id\x18\x02 \x01(\tR\amodelId\x12!\n" + + "\ftimeout_secs\x18\x03 \x01(\x04R\vtimeoutSecs\"\xb0\x01\n" + + "\x0eInferenceRoute\x12>\n" + + "\bmetadata\x18\x01 \x01(\v2\".openshell.datamodel.v1.ObjectMetaR\bmetadata\x12D\n" + + "\x06config\x18\x02 \x01(\v2,.openshell.inference.v1.InferenceRouteConfigR\x06config\x12\x18\n" + + "\aversion\x18\x03 \x01(\x04R\aversion\"\xef\x01\n" + + "\x18SetInferenceRouteRequest\x12#\n" + + "\rprovider_name\x18\x01 \x01(\tR\fproviderName\x12\x19\n" + + "\bmodel_id\x18\x02 \x01(\tR\amodelId\x12\x1d\n" + + "\n" + + "route_name\x18\x03 \x01(\tR\trouteName\x12\x16\n" + + "\x06verify\x18\x04 \x01(\bR\x06verify\x12\x1b\n" + + "\tno_verify\x18\x05 \x01(\bR\bnoVerify\x12!\n" + + "\ftimeout_secs\x18\x06 \x01(\x04R\vtimeoutSecs\x12\x1c\n" + + "\tworkspace\x18\a \x01(\tR\tworkspace\"A\n" + + "\x11ValidatedEndpoint\x12\x10\n" + + "\x03url\x18\x01 \x01(\tR\x03url\x12\x1a\n" + + "\bprotocol\x18\x02 \x01(\tR\bprotocol\"\xe4\x02\n" + + "\x19SetInferenceRouteResponse\x12#\n" + + "\rprovider_name\x18\x01 \x01(\tR\fproviderName\x12\x19\n" + + "\bmodel_id\x18\x02 \x01(\tR\amodelId\x12\x18\n" + + "\aversion\x18\x03 \x01(\x04R\aversion\x12\x1d\n" + + "\n" + + "route_name\x18\x04 \x01(\tR\trouteName\x121\n" + + "\x14validation_performed\x18\x05 \x01(\bR\x13validationPerformed\x12Z\n" + + "\x13validated_endpoints\x18\x06 \x03(\v2).openshell.inference.v1.ValidatedEndpointR\x12validatedEndpoints\x12!\n" + + "\ftimeout_secs\x18\a \x01(\x04R\vtimeoutSecs\x12\x1c\n" + + "\tworkspace\x18\b \x01(\tR\tworkspace\"W\n" + + "\x18GetInferenceRouteRequest\x12\x1d\n" + + "\n" + + "route_name\x18\x01 \x01(\tR\trouteName\x12\x1c\n" + + "\tworkspace\x18\x02 \x01(\tR\tworkspace\"\xd5\x01\n" + + "\x19GetInferenceRouteResponse\x12#\n" + + "\rprovider_name\x18\x01 \x01(\tR\fproviderName\x12\x19\n" + + "\bmodel_id\x18\x02 \x01(\tR\amodelId\x12\x18\n" + + "\aversion\x18\x03 \x01(\x04R\aversion\x12\x1d\n" + + "\n" + + "route_name\x18\x04 \x01(\tR\trouteName\x12!\n" + + "\ftimeout_secs\x18\x05 \x01(\x04R\vtimeoutSecs\x12\x1c\n" + + "\tworkspace\x18\x06 \x01(\tR\tworkspace\"Z\n" + + "\x1bDeleteInferenceRouteRequest\x12\x1d\n" + + "\n" + + "route_name\x18\x01 \x01(\tR\trouteName\x12\x1c\n" + + "\tworkspace\x18\x02 \x01(\tR\tworkspace\"8\n" + + "\x1cDeleteInferenceRouteResponse\x12\x18\n" + + "\adeleted\x18\x01 \x01(\bR\adeleted\"\x1b\n" + + "\x19GetInferenceBundleRequest\"\xd5\x02\n" + + "\rResolvedRoute\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x19\n" + + "\bbase_url\x18\x02 \x01(\tR\abaseUrl\x12\x1c\n" + + "\tprotocols\x18\x03 \x03(\tR\tprotocols\x12\x1d\n" + + "\aapi_key\x18\x04 \x01(\tB\x04\x88\xb5\x18\x01R\x06apiKey\x12\x19\n" + + "\bmodel_id\x18\x05 \x01(\tR\amodelId\x12#\n" + + "\rprovider_type\x18\x06 \x01(\tR\fproviderType\x12!\n" + + "\ftimeout_secs\x18\a \x01(\x04R\vtimeoutSecs\x12\"\n" + + "\rmodel_in_path\x18\b \x01(\bR\vmodelInPath\x127\n" + + "\x15request_path_override\x18\t \x01(\tH\x00R\x13requestPathOverride\x88\x01\x01B\x18\n" + + "\x16_request_path_override\"\x9f\x01\n" + + "\x1aGetInferenceBundleResponse\x12=\n" + + "\x06routes\x18\x01 \x03(\v2%.openshell.inference.v1.ResolvedRouteR\x06routes\x12\x1a\n" + + "\brevision\x18\x02 \x01(\tR\brevision\x12&\n" + + "\x0fgenerated_at_ms\x18\x03 \x01(\x03R\rgeneratedAtMs2\x82\x05\n" + + "\tInference\x12\x8a\x01\n" + + "\x12GetInferenceBundle\x121.openshell.inference.v1.GetInferenceBundleRequest\x1a2.openshell.inference.v1.GetInferenceBundleResponse\"\r\x82\xb5\x18\t\n" + + "\asandbox\x12\x9e\x01\n" + + "\x11SetInferenceRoute\x120.openshell.inference.v1.SetInferenceRouteRequest\x1a1.openshell.inference.v1.SetInferenceRouteResponse\"$\x82\xb5\x18 \n" + + "\x06bearer\x12\x05admin\"\x0finference:write\x12\x9c\x01\n" + + "\x11GetInferenceRoute\x120.openshell.inference.v1.GetInferenceRouteRequest\x1a1.openshell.inference.v1.GetInferenceRouteResponse\"\"\x82\xb5\x18\x1e\n" + + "\x06bearer\x12\x04user\"\x0einference:read\x12\xa7\x01\n" + + "\x14DeleteInferenceRoute\x123.openshell.inference.v1.DeleteInferenceRouteRequest\x1a4.openshell.inference.v1.DeleteInferenceRouteResponse\"$\x82\xb5\x18 \n" + + "\x06bearer\x12\x05admin\"\x0finference:writeb\x06proto3" + +var ( + file_inference_proto_rawDescOnce sync.Once + file_inference_proto_rawDescData []byte +) + +func file_inference_proto_rawDescGZIP() []byte { + file_inference_proto_rawDescOnce.Do(func() { + file_inference_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_inference_proto_rawDesc), len(file_inference_proto_rawDesc))) + }) + return file_inference_proto_rawDescData +} + +var file_inference_proto_msgTypes = make([]protoimpl.MessageInfo, 12) +var file_inference_proto_goTypes = []any{ + (*InferenceRouteConfig)(nil), // 0: openshell.inference.v1.InferenceRouteConfig + (*InferenceRoute)(nil), // 1: openshell.inference.v1.InferenceRoute + (*SetInferenceRouteRequest)(nil), // 2: openshell.inference.v1.SetInferenceRouteRequest + (*ValidatedEndpoint)(nil), // 3: openshell.inference.v1.ValidatedEndpoint + (*SetInferenceRouteResponse)(nil), // 4: openshell.inference.v1.SetInferenceRouteResponse + (*GetInferenceRouteRequest)(nil), // 5: openshell.inference.v1.GetInferenceRouteRequest + (*GetInferenceRouteResponse)(nil), // 6: openshell.inference.v1.GetInferenceRouteResponse + (*DeleteInferenceRouteRequest)(nil), // 7: openshell.inference.v1.DeleteInferenceRouteRequest + (*DeleteInferenceRouteResponse)(nil), // 8: openshell.inference.v1.DeleteInferenceRouteResponse + (*GetInferenceBundleRequest)(nil), // 9: openshell.inference.v1.GetInferenceBundleRequest + (*ResolvedRoute)(nil), // 10: openshell.inference.v1.ResolvedRoute + (*GetInferenceBundleResponse)(nil), // 11: openshell.inference.v1.GetInferenceBundleResponse + (*datamodelv1.ObjectMeta)(nil), // 12: openshell.datamodel.v1.ObjectMeta +} +var file_inference_proto_depIdxs = []int32{ + 12, // 0: openshell.inference.v1.InferenceRoute.metadata:type_name -> openshell.datamodel.v1.ObjectMeta + 0, // 1: openshell.inference.v1.InferenceRoute.config:type_name -> openshell.inference.v1.InferenceRouteConfig + 3, // 2: openshell.inference.v1.SetInferenceRouteResponse.validated_endpoints:type_name -> openshell.inference.v1.ValidatedEndpoint + 10, // 3: openshell.inference.v1.GetInferenceBundleResponse.routes:type_name -> openshell.inference.v1.ResolvedRoute + 9, // 4: openshell.inference.v1.Inference.GetInferenceBundle:input_type -> openshell.inference.v1.GetInferenceBundleRequest + 2, // 5: openshell.inference.v1.Inference.SetInferenceRoute:input_type -> openshell.inference.v1.SetInferenceRouteRequest + 5, // 6: openshell.inference.v1.Inference.GetInferenceRoute:input_type -> openshell.inference.v1.GetInferenceRouteRequest + 7, // 7: openshell.inference.v1.Inference.DeleteInferenceRoute:input_type -> openshell.inference.v1.DeleteInferenceRouteRequest + 11, // 8: openshell.inference.v1.Inference.GetInferenceBundle:output_type -> openshell.inference.v1.GetInferenceBundleResponse + 4, // 9: openshell.inference.v1.Inference.SetInferenceRoute:output_type -> openshell.inference.v1.SetInferenceRouteResponse + 6, // 10: openshell.inference.v1.Inference.GetInferenceRoute:output_type -> openshell.inference.v1.GetInferenceRouteResponse + 8, // 11: openshell.inference.v1.Inference.DeleteInferenceRoute:output_type -> openshell.inference.v1.DeleteInferenceRouteResponse + 8, // [8:12] is the sub-list for method output_type + 4, // [4:8] is the sub-list for method input_type + 4, // [4:4] is the sub-list for extension type_name + 4, // [4:4] is the sub-list for extension extendee + 0, // [0:4] is the sub-list for field type_name +} + +func init() { file_inference_proto_init() } +func file_inference_proto_init() { + if File_inference_proto != nil { + return + } + file_inference_proto_msgTypes[10].OneofWrappers = []any{} + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: unsafe.Slice(unsafe.StringData(file_inference_proto_rawDesc), len(file_inference_proto_rawDesc)), + NumEnums: 0, + NumMessages: 12, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_inference_proto_goTypes, + DependencyIndexes: file_inference_proto_depIdxs, + MessageInfos: file_inference_proto_msgTypes, + }.Build() + File_inference_proto = out.File + file_inference_proto_goTypes = nil + file_inference_proto_depIdxs = nil +} diff --git a/sdk/go/proto/inferencev1/inference_grpc.pb.go b/sdk/go/proto/inferencev1/inference_grpc.pb.go new file mode 100644 index 0000000000..61f74348c0 --- /dev/null +++ b/sdk/go/proto/inferencev1/inference_grpc.pb.go @@ -0,0 +1,256 @@ +// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.6.2 +// - protoc (unknown) +// source: inference.proto + +package inferencev1 + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.64.0 or later. +const _ = grpc.SupportPackageIsVersion9 + +const ( + Inference_GetInferenceBundle_FullMethodName = "/openshell.inference.v1.Inference/GetInferenceBundle" + Inference_SetInferenceRoute_FullMethodName = "/openshell.inference.v1.Inference/SetInferenceRoute" + Inference_GetInferenceRoute_FullMethodName = "/openshell.inference.v1.Inference/GetInferenceRoute" + Inference_DeleteInferenceRoute_FullMethodName = "/openshell.inference.v1.Inference/DeleteInferenceRoute" +) + +// InferenceClient is the client API for Inference service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +// +// Inference service provides workspace-scoped inference route configuration and bundle delivery. +type InferenceClient interface { + // Return the resolved inference route bundle for sandbox-local execution. + GetInferenceBundle(ctx context.Context, in *GetInferenceBundleRequest, opts ...grpc.CallOption) (*GetInferenceBundleResponse, error) + // Set the inference route for a workspace. + // + // This controls how requests sent to `inference.local` are routed + // for sandboxes in the specified workspace. + SetInferenceRoute(ctx context.Context, in *SetInferenceRouteRequest, opts ...grpc.CallOption) (*SetInferenceRouteResponse, error) + // Get the inference route for a workspace. + GetInferenceRoute(ctx context.Context, in *GetInferenceRouteRequest, opts ...grpc.CallOption) (*GetInferenceRouteResponse, error) + // Delete an inference route from a workspace. + DeleteInferenceRoute(ctx context.Context, in *DeleteInferenceRouteRequest, opts ...grpc.CallOption) (*DeleteInferenceRouteResponse, error) +} + +type inferenceClient struct { + cc grpc.ClientConnInterface +} + +func NewInferenceClient(cc grpc.ClientConnInterface) InferenceClient { + return &inferenceClient{cc} +} + +func (c *inferenceClient) GetInferenceBundle(ctx context.Context, in *GetInferenceBundleRequest, opts ...grpc.CallOption) (*GetInferenceBundleResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInferenceBundleResponse) + err := c.cc.Invoke(ctx, Inference_GetInferenceBundle_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *inferenceClient) SetInferenceRoute(ctx context.Context, in *SetInferenceRouteRequest, opts ...grpc.CallOption) (*SetInferenceRouteResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetInferenceRouteResponse) + err := c.cc.Invoke(ctx, Inference_SetInferenceRoute_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *inferenceClient) GetInferenceRoute(ctx context.Context, in *GetInferenceRouteRequest, opts ...grpc.CallOption) (*GetInferenceRouteResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(GetInferenceRouteResponse) + err := c.cc.Invoke(ctx, Inference_GetInferenceRoute_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *inferenceClient) DeleteInferenceRoute(ctx context.Context, in *DeleteInferenceRouteRequest, opts ...grpc.CallOption) (*DeleteInferenceRouteResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(DeleteInferenceRouteResponse) + err := c.cc.Invoke(ctx, Inference_DeleteInferenceRoute_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +// InferenceServer is the server API for Inference service. +// All implementations must embed UnimplementedInferenceServer +// for forward compatibility. +// +// Inference service provides workspace-scoped inference route configuration and bundle delivery. +type InferenceServer interface { + // Return the resolved inference route bundle for sandbox-local execution. + GetInferenceBundle(context.Context, *GetInferenceBundleRequest) (*GetInferenceBundleResponse, error) + // Set the inference route for a workspace. + // + // This controls how requests sent to `inference.local` are routed + // for sandboxes in the specified workspace. + SetInferenceRoute(context.Context, *SetInferenceRouteRequest) (*SetInferenceRouteResponse, error) + // Get the inference route for a workspace. + GetInferenceRoute(context.Context, *GetInferenceRouteRequest) (*GetInferenceRouteResponse, error) + // Delete an inference route from a workspace. + DeleteInferenceRoute(context.Context, *DeleteInferenceRouteRequest) (*DeleteInferenceRouteResponse, error) + mustEmbedUnimplementedInferenceServer() +} + +// UnimplementedInferenceServer must be embedded to have +// forward compatible implementations. +// +// NOTE: this should be embedded by value instead of pointer to avoid a nil +// pointer dereference when methods are called. +type UnimplementedInferenceServer struct{} + +func (UnimplementedInferenceServer) GetInferenceBundle(context.Context, *GetInferenceBundleRequest) (*GetInferenceBundleResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInferenceBundle not implemented") +} +func (UnimplementedInferenceServer) SetInferenceRoute(context.Context, *SetInferenceRouteRequest) (*SetInferenceRouteResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetInferenceRoute not implemented") +} +func (UnimplementedInferenceServer) GetInferenceRoute(context.Context, *GetInferenceRouteRequest) (*GetInferenceRouteResponse, error) { + return nil, status.Error(codes.Unimplemented, "method GetInferenceRoute not implemented") +} +func (UnimplementedInferenceServer) DeleteInferenceRoute(context.Context, *DeleteInferenceRouteRequest) (*DeleteInferenceRouteResponse, error) { + return nil, status.Error(codes.Unimplemented, "method DeleteInferenceRoute not implemented") +} +func (UnimplementedInferenceServer) mustEmbedUnimplementedInferenceServer() {} +func (UnimplementedInferenceServer) testEmbeddedByValue() {} + +// UnsafeInferenceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to InferenceServer will +// result in compilation errors. +type UnsafeInferenceServer interface { + mustEmbedUnimplementedInferenceServer() +} + +func RegisterInferenceServer(s grpc.ServiceRegistrar, srv InferenceServer) { + // If the following call panics, it indicates UnimplementedInferenceServer was + // embedded by pointer and is nil. This will cause panics if an + // unimplemented method is ever invoked, so we test this at initialization + // time to prevent it from happening at runtime later due to I/O. + if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { + t.testEmbeddedByValue() + } + s.RegisterService(&Inference_ServiceDesc, srv) +} + +func _Inference_GetInferenceBundle_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInferenceBundleRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InferenceServer).GetInferenceBundle(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Inference_GetInferenceBundle_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InferenceServer).GetInferenceBundle(ctx, req.(*GetInferenceBundleRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Inference_SetInferenceRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetInferenceRouteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InferenceServer).SetInferenceRoute(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Inference_SetInferenceRoute_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InferenceServer).SetInferenceRoute(ctx, req.(*SetInferenceRouteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Inference_GetInferenceRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetInferenceRouteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InferenceServer).GetInferenceRoute(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Inference_GetInferenceRoute_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InferenceServer).GetInferenceRoute(ctx, req.(*GetInferenceRouteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _Inference_DeleteInferenceRoute_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteInferenceRouteRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(InferenceServer).DeleteInferenceRoute(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: Inference_DeleteInferenceRoute_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(InferenceServer).DeleteInferenceRoute(ctx, req.(*DeleteInferenceRouteRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// Inference_ServiceDesc is the grpc.ServiceDesc for Inference service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var Inference_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "openshell.inference.v1.Inference", + HandlerType: (*InferenceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "GetInferenceBundle", + Handler: _Inference_GetInferenceBundle_Handler, + }, + { + MethodName: "SetInferenceRoute", + Handler: _Inference_SetInferenceRoute_Handler, + }, + { + MethodName: "GetInferenceRoute", + Handler: _Inference_GetInferenceRoute_Handler, + }, + { + MethodName: "DeleteInferenceRoute", + Handler: _Inference_DeleteInferenceRoute_Handler, + }, + }, + Streams: []grpc.StreamDesc{}, + Metadata: "inference.proto", +} diff --git a/tasks/go.toml b/tasks/go.toml index 2a80b9a499..81091f2912 100644 --- a/tasks/go.toml +++ b/tasks/go.toml @@ -104,6 +104,9 @@ run = """ #!/usr/bin/env bash set -euo pipefail +SDK_ROOT=$(pwd -P) +REPO_ROOT=$(cd ../.. && pwd -P) + for tool in buf protoc-gen-go protoc-gen-go-grpc; do if ! command -v "$tool" &>/dev/null; then echo "ERROR: $tool not found. Run 'mise install' to install it." @@ -111,16 +114,23 @@ for tool in buf protoc-gen-go protoc-gen-go-grpc; do fi done +if find proto -maxdepth 1 -name '*.proto' -print -quit | grep -q .; then + echo "ERROR: sdk/go/proto must contain generated bindings only." + echo "Proto sources belong in the repository root proto/ directory." + exit 1 +fi + # Clean previous output before regeneration find proto -name '*.pb.go' -delete 2>/dev/null || true +find proto -mindepth 1 -type d -empty -delete 2>/dev/null || true -buf generate +(cd "$REPO_ROOT" && buf generate --template "$SDK_ROOT/buf.gen.yaml") echo "Proto generation complete." echo "Generated packages:" -for pkg in openshellv1 datamodelv1 sandboxv1 optionsv1; do - count=$(find "proto/$pkg" -name '*.go' 2>/dev/null | wc -l | tr -d ' ') - echo " proto/$pkg/: $count files" +for pkg_dir in proto/*/; do + count=$(find "$pkg_dir" -maxdepth 1 -name '*.go' | wc -l | tr -d ' ') + echo " $pkg_dir: $count files" done """ hide = true @@ -132,6 +142,9 @@ run = """ #!/usr/bin/env bash set -euo pipefail +SDK_ROOT=$(pwd -P) +REPO_ROOT=$(cd ../.. && pwd -P) + for tool in buf protoc-gen-go protoc-gen-go-grpc; do if ! command -v "$tool" &>/dev/null; then echo "ERROR: $tool not found. Run 'mise install' to install it." @@ -142,13 +155,17 @@ done WORK_DIR=$(mktemp -d) trap 'rm -rf "$WORK_DIR"' EXIT +if find proto -maxdepth 1 -name '*.proto' -print -quit | grep -q .; then + echo "ERROR: sdk/go/proto contains copied proto sources." + echo "Proto sources belong in the repository root proto/ directory." + exit 1 +fi + # Generate to temp directory with adjusted output path -sed "s|out: \\.|out: $WORK_DIR|" buf.gen.yaml > "$WORK_DIR/buf.gen.yaml" -buf generate --template "$WORK_DIR/buf.gen.yaml" +CHECK_TEMPLATE=$(sed 's|out: sdk/go|out: '"$WORK_DIR"'|' buf.gen.yaml) +(cd "$REPO_ROOT" && buf generate --template "$CHECK_TEMPLATE") -DIFF_OUTPUT=$(diff -r "$WORK_DIR/proto" "proto" \ - --exclude="*.proto" \ - 2>&1) || true +DIFF_OUTPUT=$(diff -r "$WORK_DIR/proto" "$SDK_ROOT/proto" 2>&1) || true if [ -n "$DIFF_OUTPUT" ]; then echo "ERROR: Generated proto files are out of date."