diff --git a/AGENTS.md b/AGENTS.md index 954ffe1147..70838f3b2a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -225,6 +225,7 @@ ocsf_emit!(event); - Domain types in `sdk/go/openshell/v1/types/` must not import proto packages. - Converters in `sdk/go/openshell/v1/internal/converter/` deep-copy slices and maps at boundaries. - Tests use bufconn for in-process gRPC and testify for assertions. +- E2E tests live in `e2e/go/` (a separate Go module using a `replace` directive to `sdk/go/`, gated by the `e2e` build tag) and connect to a real gateway via `gateway.NewClient`. Run `mise run e2e:go`. ## TypeScript SDK (`sdk/typescript/`) diff --git a/e2e/go/go.mod b/e2e/go/go.mod new file mode 100644 index 0000000000..50c23184e6 --- /dev/null +++ b/e2e/go/go.mod @@ -0,0 +1,24 @@ +module github.com/NVIDIA/OpenShell/e2e/go + +go 1.25.13 + +require ( + github.com/NVIDIA/OpenShell/sdk/go v0.0.0-00010101000000-000000000000 + github.com/stretchr/testify v1.11.1 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + golang.org/x/net v0.58.0 // indirect + golang.org/x/oauth2 v0.36.0 // indirect + golang.org/x/sync v0.22.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect + google.golang.org/grpc v1.83.2 // indirect + google.golang.org/protobuf v1.36.11 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +replace github.com/NVIDIA/OpenShell/sdk/go => ../../sdk/go diff --git a/e2e/go/go.sum b/e2e/go/go.sum new file mode 100644 index 0000000000..184fd72f18 --- /dev/null +++ b/e2e/go/go.sum @@ -0,0 +1,52 @@ +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/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= +github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +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.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To= +golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU= +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.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +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-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU= +google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8= +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= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/e2e/go/harness_test.go b/e2e/go/harness_test.go new file mode 100644 index 0000000000..1ba4e79f71 --- /dev/null +++ b/e2e/go/harness_test.go @@ -0,0 +1,144 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build e2e + +// Package e2e contains end-to-end tests for the Go SDK, run against a real +// OpenShell gateway (see e2e/with-docker-gateway.sh and `mise run e2e:go`). +package e2e + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/gateway" + "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1/types" +) + +var ( + sharedClient *v1.Client + gatewayName string +) + +func TestMain(m *testing.M) { + gatewayName = os.Getenv("OPENSHELL_GATEWAY") + if gatewayName == "" { + // No gateway configured; requireClient skips every test individually. + os.Exit(m.Run()) + } + + cfg, err := gateway.LoadConfig(gatewayName) + if err != nil { + fmt.Fprintf(os.Stderr, "e2e: failed to load gateway config: %v\n", err) + os.Exit(1) + } + + var opts []gateway.ClientOption + if opt := mtlsClientOption(cfg.Dir); opt != nil { + opts = append(opts, opt) + } + + client, err := gateway.NewClient(gatewayName, opts...) + if err != nil { + fmt.Fprintf(os.Stderr, "e2e: failed to build gateway client: %v\n", err) + os.Exit(1) + } + sharedClient = client + + if err := waitForPersistenceReady(sharedClient); err != nil { + fmt.Fprintf(os.Stderr, "e2e: %v\n", err) + _ = sharedClient.Close() + os.Exit(1) + } + + code := m.Run() + _ = sharedClient.Close() + os.Exit(code) +} + +// mtlsClientOption inspects /mtls for an on-disk certificate bundle +// written by e2e_register_mtls_gateway (see e2e/support/gateway-common.sh) +// and returns a ClientOption applying it. Returns nil when no bundle is +// present (e.g. plaintext or OIDC e2e lanes), so callers should skip it. +func mtlsClientOption(dir string) gateway.ClientOption { + mtlsDir := filepath.Join(dir, "mtls") + caPath := filepath.Join(mtlsDir, "ca.crt") + if _, err := os.Stat(caPath); err != nil { + return nil + } + + cfg := &types.TLSConfig{CAFile: caPath} + + certPath := filepath.Join(mtlsDir, "tls.crt") + keyPath := filepath.Join(mtlsDir, "tls.key") + if _, err := os.Stat(certPath); err == nil { + if _, err := os.Stat(keyPath); err == nil { + cfg.CertFile = certPath + cfg.KeyFile = keyPath + } + } + + return gateway.WithTLS(cfg) +} + +// requireClient returns the shared gateway client, skipping the calling test +// when OPENSHELL_GATEWAY was not set at process startup. +func requireClient(t *testing.T) *v1.Client { + t.Helper() + if gatewayName == "" { + t.Skip("OPENSHELL_GATEWAY not set") + } + return sharedClient +} + +// waitForPersistenceReady polls the gateway until its persistence layer is +// initialized, tolerating the transient errors observed right after the +// gateway process starts (transport not yet listening, sqlite migrations +// not yet applied). +func waitForPersistenceReady(client *v1.Client) error { + ctx := context.Background() + var lastErr error + for range 60 { + _, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{PageSize: 1}) + if err == nil { + return nil + } + lastErr = err + if v1.IsUnavailable(err) { + time.Sleep(2 * time.Second) + continue + } + if strings.Contains(err.Error(), "no such table: objects") { + time.Sleep(1 * time.Second) + continue + } + return fmt.Errorf("unexpected error waiting for persistence: %w", err) + } + return fmt.Errorf("openshell-server persistence is not initialized after 60 attempts: %w", lastErr) +} + +// uniqueName returns a process-unique resource name so parallel tests never +// collide on the same sandbox/workspace/provider name. +func uniqueName(prefix string) string { + return fmt.Sprintf("%s-%09d", prefix, time.Now().UnixNano()%1_000_000_000) +} + +// defaultPolicy returns a baseline sandbox policy sufficient for exec-based tests. +func defaultPolicy() *v1.SandboxPolicy { + return &v1.SandboxPolicy{ + Version: 1, + Filesystem: &v1.FilesystemPolicy{ + IncludeWorkdir: true, + ReadOnly: []string{"/usr", "/lib", "/etc", "/app", "/dev/urandom"}, + ReadWrite: []string{"/sandbox", "/tmp"}, + }, + Landlock: &v1.LandlockPolicy{Compatibility: "best_effort"}, + Process: &v1.ProcessPolicy{RunAsUser: "sandbox", RunAsGroup: "sandbox"}, + } +} diff --git a/e2e/go/providers_test.go b/e2e/go/providers_test.go new file mode 100644 index 0000000000..1bab0e88e2 --- /dev/null +++ b/e2e/go/providers_test.go @@ -0,0 +1,332 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build e2e + +// Provider credentials are fetched at runtime by the sandbox supervisor. +// Sandboxed child processes must see placeholder values (never raw secrets), +// and only when a provider is actually attached to the sandbox. +package e2e + +import ( + "context" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +func isPlaceholderForEnvKey(value, key string) bool { + const prefix = "openshell:resolve:env:" + if value == prefix+key { + return true + } + token, ok := strings.CutPrefix(value, prefix) + if !ok { + return false + } + return strings.HasPrefix(token, "v") && strings.HasSuffix(token, "_"+key) +} + +// requireProviderResponse checks the stable public fields returned by provider +// APIs. Credentials are write-only and intentionally omitted from Go SDK +// provider responses. +func requireProviderResponse(t *testing.T, provider *v1.Provider, workspace, name, providerType string) { + t.Helper() + require.NotNil(t, provider) + require.NotEmpty(t, provider.ID) + require.Equal(t, workspace, provider.Workspace) + require.Equal(t, name, provider.Name) + require.Equal(t, providerType, provider.Type) + require.NotZero(t, provider.ResourceVersion) + require.Empty(t, provider.Spec.Credentials) +} + +func requireReadySandbox(t *testing.T, sandbox *v1.Sandbox, id string) { + t.Helper() + require.NotNil(t, sandbox) + require.Equal(t, id, sandbox.ID) + require.Equal(t, v1.SandboxReady, sandbox.Status.Phase) +} + +func readEnvVar(ctx context.Context, client *v1.Client, workspace, sandboxName, key string) (string, error) { + result, err := client.Exec().Run(ctx, workspace, sandboxName, + []string{"sh", "-c", fmt.Sprintf(`printf "%%s" "${%s:-NOT_SET}"`, key)}, v1.ExecOptions{}) + if err != nil { + return "", err + } + if result.ExitCode != 0 { + return "", fmt.Errorf("exec exited %d: %s", result.ExitCode, string(result.Stderr)) + } + return string(result.Stdout), nil +} + +func deleteProviderIgnoreNotFound(ctx context.Context, t *testing.T, client *v1.Client, workspace, name string) { + t.Helper() + err := client.Providers().Delete(ctx, workspace, name) + if err != nil && !v1.IsNotFound(err) { + require.NoError(t, err) + } +} + +// detachProviderBestEffort clears a provider from a sandbox's spec before the +// sandbox is deleted. Sandbox deletion removes the gateway's Sandbox record +// asynchronously (via a background watcher), so a provider-delete cleanup +// racing right behind a sandbox-delete cleanup can still see the sandbox as +// attached and fail with a conflict unless the attachment is cleared +// synchronously first. +func detachProviderBestEffort(ctx context.Context, client *v1.Client, workspace, sandboxName, providerName string) { + if current, err := client.Sandboxes().Get(ctx, workspace, sandboxName); err == nil { + _, _ = client.Sandboxes().DetachProvider(ctx, workspace, sandboxName, providerName, current.ResourceVersion) + } +} + +// attachProviderRetry attaches a provider to a sandbox, retrying with a +// freshly fetched resource version whenever the gateway reports a +// concurrent-modification conflict (e.g. a status update racing the +// optimistic-concurrency check between Get and AttachProvider). +func attachProviderRetry(ctx context.Context, t *testing.T, client *v1.Client, workspace, sandboxName, providerName string) *v1.AttachProviderResult { + t.Helper() + for { + current, err := client.Sandboxes().Get(ctx, workspace, sandboxName) + require.NoError(t, err) + result, err := client.Sandboxes().AttachProvider(ctx, workspace, sandboxName, providerName, current.ResourceVersion) + if err == nil { + return result + } + if !v1.IsConflict(err) { + require.NoError(t, err) + } + select { + case <-ctx.Done(): + t.Fatalf("context done while retrying attach provider: %v", ctx.Err()) + case <-time.After(200 * time.Millisecond): + } + } +} + +// detachProviderRetry mirrors attachProviderRetry for detach. +func detachProviderRetry(ctx context.Context, t *testing.T, client *v1.Client, workspace, sandboxName, providerName string) *v1.DetachProviderResult { + t.Helper() + for { + current, err := client.Sandboxes().Get(ctx, workspace, sandboxName) + require.NoError(t, err) + result, err := client.Sandboxes().DetachProvider(ctx, workspace, sandboxName, providerName, current.ResourceVersion) + if err == nil { + return result + } + if !v1.IsConflict(err) { + require.NoError(t, err) + } + select { + case <-ctx.Done(): + t.Fatalf("context done while retrying detach provider: %v", ctx.Err()) + case <-time.After(200 * time.Millisecond): + } + } +} + +func TestProviderCredentialsAvailableAsEnvVar(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + providerName := uniqueName("prov-env") + const secret = "sk-e2e-test-key-12345" + provider, err := client.Providers().Create(ctx, "default", &v1.Provider{ + Name: providerName, + Type: "claude", + Spec: v1.ProviderSpec{Credentials: map[string]string{"ANTHROPIC_API_KEY": secret}}, + }) + require.NoError(t, err) + requireProviderResponse(t, provider, "default", providerName, "claude-code") + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + deleteProviderIgnoreNotFound(cleanupCtx, t, client, "default", providerName) + }) + + sandboxName := uniqueName("sb-penv") + sandbox, err := client.Sandboxes().Create(ctx, "default", sandboxName, &v1.SandboxSpec{ + Policy: defaultPolicy(), + Providers: []string{providerName}, + }, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + detachProviderBestEffort(cleanupCtx, client, "default", sandboxName, providerName) + require.NoError(t, client.Sandboxes().Delete(cleanupCtx, "default", sandboxName)) + }) + + ready, err := client.Sandboxes().WaitReady(ctx, "default", sandboxName) + require.NoError(t, err) + requireReadySandbox(t, ready, sandbox.ID) + + value, err := readEnvVar(ctx, client, "default", sandboxName, "ANTHROPIC_API_KEY") + require.NoError(t, err) + require.True(t, isPlaceholderForEnvKey(value, "ANTHROPIC_API_KEY"), "expected a placeholder, got %q", value) + require.NotEqual(t, secret, value) +} + +func TestProfilelessProviderCreationIsRejected(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + providerName := uniqueName("prov-generic") + _, err := client.Providers().Create(ctx, "default", &v1.Provider{ + Name: providerName, + Type: "generic", + Spec: v1.ProviderSpec{Credentials: map[string]string{ + "CUSTOM_SERVICE_TOKEN": "token-generic-123", + "CUSTOM_SERVICE_URL": "https://internal.example.test/api", + }}, + }) + require.Error(t, err) + require.True(t, v1.IsInvalidArgument(err), "expected InvalidArgument, got: %v", err) + require.ErrorContains(t, err, "provider profile 'generic' was not found") +} + +func TestAttachDetachProviderUpdatesCredentials(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + providerName := uniqueName("prov-attach") + provider, err := client.Providers().Create(ctx, "default", &v1.Provider{ + Name: providerName, + Type: "nvidia", + Spec: v1.ProviderSpec{Credentials: map[string]string{"NVIDIA_API_KEY": "nvapi-e2e-test-key"}}, + }) + require.NoError(t, err) + requireProviderResponse(t, provider, "default", providerName, "nvidia") + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + deleteProviderIgnoreNotFound(cleanupCtx, t, client, "default", providerName) + }) + + sandboxName := uniqueName("sb-patt") + sandbox, err := client.Sandboxes().Create(ctx, "default", sandboxName, &v1.SandboxSpec{Policy: defaultPolicy()}, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + detachProviderBestEffort(cleanupCtx, client, "default", sandboxName, providerName) + require.NoError(t, client.Sandboxes().Delete(cleanupCtx, "default", sandboxName)) + }) + + ready, err := client.Sandboxes().WaitReady(ctx, "default", sandboxName) + require.NoError(t, err) + requireReadySandbox(t, ready, sandbox.ID) + + value, err := readEnvVar(ctx, client, "default", sandboxName, "NVIDIA_API_KEY") + require.NoError(t, err) + require.Equal(t, "NOT_SET", value) + + attachResult := attachProviderRetry(ctx, t, client, "default", sandboxName, providerName) + require.True(t, attachResult.Attached) + + value = pollUntil(t, ctx, func() string { + v, err := readEnvVar(ctx, client, "default", sandboxName, "NVIDIA_API_KEY") + require.NoError(t, err) + return v + }, func(v string) bool { return v != "NOT_SET" }) + require.True(t, isPlaceholderForEnvKey(value, "NVIDIA_API_KEY"), "expected a placeholder, got %q", value) + + detachResult := detachProviderRetry(ctx, t, client, "default", sandboxName, providerName) + require.True(t, detachResult.Detached) + + value = pollUntil(t, ctx, func() string { + v, err := readEnvVar(ctx, client, "default", sandboxName, "NVIDIA_API_KEY") + require.NoError(t, err) + return v + }, func(v string) bool { return v == "NOT_SET" }) + require.Equal(t, "NOT_SET", value) +} + +func TestProviderCRUDResponseContract(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + providerName := uniqueName("prov-crud") + const secret = "sk-e2e-provider-crud-12345" + created, err := client.Providers().Create(ctx, "default", &v1.Provider{ + Name: providerName, + Type: "claude", + Labels: map[string]string{"e2e": "provider-response-contract"}, + Spec: v1.ProviderSpec{ + Credentials: map[string]string{"ANTHROPIC_API_KEY": secret}, + }, + }) + require.NoError(t, err) + requireProviderResponse(t, created, "default", providerName, "claude-code") + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + deleteProviderIgnoreNotFound(cleanupCtx, t, client, "default", providerName) + }) + require.Equal(t, "provider-response-contract", created.Labels["e2e"]) + + fetched, err := client.Providers().Get(ctx, "default", providerName) + require.NoError(t, err) + requireProviderResponse(t, fetched, "default", providerName, "claude-code") + require.Equal(t, created.ID, fetched.ID) + + updatedInput := *fetched + // Get omits write-only credentials, so leave them unchanged in this update. + updatedInput.Spec.Credentials = nil + updatedInput.Spec.Config = map[string]string{"e2e-response-contract": "updated"} + updated, err := client.Providers().Update(ctx, "default", &updatedInput) + require.NoError(t, err) + requireProviderResponse(t, updated, "default", providerName, "claude-code") + require.Equal(t, "updated", updated.Spec.Config["e2e-response-contract"]) + require.Greater(t, updated.ResourceVersion, fetched.ResourceVersion) + + providers, err := client.Providers().ListAll(ctx, "default") + require.NoError(t, err) + for _, listed := range providers { + if listed.Name == providerName { + requireProviderResponse(t, listed, "default", providerName, "claude-code") + require.Equal(t, updated.ID, listed.ID) + require.Equal(t, "updated", listed.Spec.Config["e2e-response-contract"]) + return + } + } + t.Fatalf("expected provider %q in List response", providerName) +} + +// pollUntil calls fetch until predicate is satisfied or ctx carries a +// deadline of 35 seconds, whichever comes first. It fails the test on timeout. +func pollUntil(t *testing.T, ctx context.Context, fetch func() string, predicate func(string) bool) string { + t.Helper() + deadline := time.Now().Add(35 * time.Second) + for { + value := fetch() + if predicate(value) { + return value + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for expected value, last observed %q", value) + } + select { + case <-ctx.Done(): + t.Fatalf("context done while polling: %v", ctx.Err()) + case <-time.After(time.Second): + } + } +} diff --git a/e2e/go/sandbox_lifecycle_test.go b/e2e/go/sandbox_lifecycle_test.go new file mode 100644 index 0000000000..34ec017145 --- /dev/null +++ b/e2e/go/sandbox_lifecycle_test.go @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build e2e + +package e2e + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +func TestSandboxCRUDAndExec(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + name := uniqueName("sb-crud") + sb, err := client.Sandboxes().Create(ctx, "default", name, &v1.SandboxSpec{ + Policy: defaultPolicy(), + }, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cleanupCancel() + require.NoError(t, client.Sandboxes().Delete(cleanupCtx, "default", name)) + }) + require.NotEmpty(t, sb.ID) + require.Equal(t, name, sb.Name) + + ready, err := client.Sandboxes().WaitReady(ctx, "default", name) + require.NoError(t, err) + requireReadySandbox(t, ready, sb.ID) + + fetched, err := client.Sandboxes().Get(ctx, "default", name) + require.NoError(t, err) + require.Equal(t, sb.ID, fetched.ID) + + sandboxes, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{PageSize: 100}) + require.NoError(t, err) + require.True(t, containsID(sandboxes, sb.ID)) + + result, err := client.Exec().Run(ctx, "default", name, []string{"sh", "-c", "printf sandbox-ok"}, v1.ExecOptions{}) + require.NoError(t, err) + require.Equal(t, 0, result.ExitCode) + require.Equal(t, "sandbox-ok", string(result.Stdout)) + + // Exec launches share the same sandbox filesystem across calls. + writeResult, err := client.Exec().Run(ctx, "default", name, + []string{"sh", "-c", "echo persisted > /sandbox/exec-persistence.txt"}, v1.ExecOptions{}) + require.NoError(t, err) + require.Equal(t, 0, writeResult.ExitCode) + + readResult, err := client.Exec().Run(ctx, "default", name, + []string{"cat", "/sandbox/exec-persistence.txt"}, v1.ExecOptions{}) + require.NoError(t, err) + require.Equal(t, 0, readResult.ExitCode) + require.Equal(t, "persisted\n", string(readResult.Stdout)) +} + +func TestSandboxListScopedAndAllWorkspaces(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + otherWorkspace := uniqueName("list-ws") + _, err := client.Workspaces().Create(ctx, otherWorkspace, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + // Sandbox deletion (registered after this cleanup, so it runs first + // under LIFO ordering) only asynchronously clears the store row via a + // background watcher. Workspace deletion is blocked while any + // sandbox still references it, so wait for the workspace to drain + // before attempting the delete. + waitForWorkspaceSandboxesGone(cleanupCtx, client, otherWorkspace) + _ = client.Workspaces().Delete(cleanupCtx, otherWorkspace) + }) + + defaultName := uniqueName("ls-def") + defaultSandbox, err := client.Sandboxes().Create(ctx, "default", defaultName, &v1.SandboxSpec{}, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.Sandboxes().Delete(cleanupCtx, "default", defaultName) + }) + + otherName := uniqueName("ls-oth") + otherSandbox, err := client.Sandboxes().Create(ctx, otherWorkspace, otherName, &v1.SandboxSpec{}, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.Sandboxes().Delete(cleanupCtx, otherWorkspace, otherName) + }) + + defaultList, err := client.Sandboxes().ListAll(ctx, "default") + require.NoError(t, err) + require.True(t, containsID(defaultList, defaultSandbox.ID)) + require.False(t, containsID(defaultList, otherSandbox.ID)) + + otherList, err := client.Sandboxes().ListAll(ctx, otherWorkspace) + require.NoError(t, err) + require.True(t, containsID(otherList, otherSandbox.ID)) + require.False(t, containsID(otherList, defaultSandbox.ID)) + + allList, err := client.Sandboxes().ListAll(ctx, "", v1.ListOptions{AllWorkspaces: true}) + require.NoError(t, err) + require.True(t, containsID(allList, defaultSandbox.ID)) + require.True(t, containsID(allList, otherSandbox.ID)) +} + +func TestSandboxLabelsAndSelectors(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + suffix := uniqueName("lbl") + jobA := fmt.Sprintf("%s-a", suffix) + jobB := fmt.Sprintf("%s-b", suffix) + groupSelector := fmt.Sprintf("aiq-test=%s", suffix) + primarySelector := fmt.Sprintf("aiq-test=%s,role=primary", suffix) + + refA, err := client.Sandboxes().Create(ctx, "default", jobA, &v1.SandboxSpec{}, + map[string]string{"aiq-test": suffix, "role": "primary"}) + require.NoError(t, err) + deletedA := false + t.Cleanup(func() { + if deletedA { + return + } + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.Sandboxes().Delete(cleanupCtx, "default", jobA) + }) + + _, err = client.Sandboxes().Create(ctx, "default", jobB, &v1.SandboxSpec{}, + map[string]string{"aiq-test": suffix, "role": "secondary"}) + require.NoError(t, err) + deletedB := false + t.Cleanup(func() { + if deletedB { + return + } + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + _ = client.Sandboxes().Delete(cleanupCtx, "default", jobB) + }) + + require.Equal(t, "primary", refA.Labels["role"]) + + fetchedA, err := client.Sandboxes().Get(ctx, "default", jobA) + require.NoError(t, err) + require.Equal(t, "primary", fetchedA.Labels["role"]) + + fetchedB, err := client.Sandboxes().Get(ctx, "default", jobB) + require.NoError(t, err) + require.Equal(t, "secondary", fetchedB.Labels["role"]) + + primaryOnly, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: primarySelector}) + require.NoError(t, err) + require.ElementsMatch(t, []string{jobA}, names(primaryOnly)) + + both, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: groupSelector}) + require.NoError(t, err) + require.ElementsMatch(t, []string{jobA, jobB}, names(both)) + + require.NoError(t, client.Sandboxes().Delete(ctx, "default", jobA)) + deletedA = true + + remaining := pollUntilNamesMatch(t, ctx, func() []string { + list, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: groupSelector}) + require.NoError(t, err) + return names(list) + }, []string{jobB}) + require.ElementsMatch(t, []string{jobB}, remaining) + + require.NoError(t, client.Sandboxes().Delete(ctx, "default", jobB)) + deletedB = true + + empty := pollUntilNamesMatch(t, ctx, func() []string { + list, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: groupSelector}) + require.NoError(t, err) + return names(list) + }, []string{}) + require.Empty(t, empty) +} + +// pollUntilNamesMatch waits for fetch to report exactly the expected set of +// sandbox names. Sandbox deletion removes the gateway's list-visible record +// asynchronously (via a background watcher draining the compute driver's +// deletion event), so a List call issued immediately after Delete can still +// return the just-deleted sandbox for a short window. +func pollUntilNamesMatch(t *testing.T, ctx context.Context, fetch func() []string, expected []string) []string { + t.Helper() + deadline := time.Now().Add(35 * time.Second) + for { + values := fetch() + if elementsMatch(values, expected) { + return values + } + if time.Now().After(deadline) { + t.Fatalf("timed out waiting for sandbox list %v, last observed %v", expected, values) + } + select { + case <-ctx.Done(): + t.Fatalf("context done while polling: %v", ctx.Err()) + case <-time.After(time.Second): + } + } +} + +func elementsMatch(a, b []string) bool { + if len(a) != len(b) { + return false + } + counts := make(map[string]int, len(a)) + for _, v := range a { + counts[v]++ + } + for _, v := range b { + counts[v]-- + } + for _, c := range counts { + if c != 0 { + return false + } + } + return true +} + +// waitForWorkspaceSandboxesGone polls until a workspace has no sandboxes +// left, up to a 20s deadline. It is best-effort: cleanup proceeds regardless +// of whether the deadline is reached, since the subsequent workspace delete +// is itself best-effort in callers of this helper. +func waitForWorkspaceSandboxesGone(ctx context.Context, client *v1.Client, workspace string) { + deadline := time.Now().Add(20 * time.Second) + for { + list, err := client.Sandboxes().ListAll(ctx, workspace) + if err == nil && len(list) == 0 { + return + } + if time.Now().After(deadline) { + return + } + select { + case <-ctx.Done(): + return + case <-time.After(time.Second): + } + } +} + +func containsID(sandboxes []*v1.Sandbox, id string) bool { + for _, sb := range sandboxes { + if sb.ID == id { + return true + } + } + return false +} + +func names(sandboxes []*v1.Sandbox) []string { + result := make([]string, 0, len(sandboxes)) + for _, sb := range sandboxes { + result = append(result, sb.Name) + } + return result +} diff --git a/e2e/go/workspace_test.go b/e2e/go/workspace_test.go new file mode 100644 index 0000000000..49230c8164 --- /dev/null +++ b/e2e/go/workspace_test.go @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +//go:build e2e + +package e2e + +import ( + "context" + "testing" + "time" + + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +func requireWorkspaceResponse(t *testing.T, workspace *v1.Workspace, name string) { + t.Helper() + require.NotNil(t, workspace) + require.NotEmpty(t, workspace.ID) + require.Equal(t, name, workspace.Name) + require.False(t, workspace.CreatedAt.IsZero()) + require.NotZero(t, workspace.ResourceVersion) +} + +func TestWorkspaceCRUD(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + name := uniqueName("ws-crud") + ws, err := client.Workspaces().Create(ctx, name, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + require.NoError(t, client.Workspaces().Delete(cleanupCtx, name)) + }) + require.Equal(t, name, ws.Name) + require.Equal(t, v1.WorkspaceActive, ws.Phase) + requireWorkspaceResponse(t, ws, name) + + fetched, err := client.Workspaces().Get(ctx, name) + require.NoError(t, err) + require.Equal(t, name, fetched.Name) + require.Equal(t, v1.WorkspaceActive, fetched.Phase) + requireWorkspaceResponse(t, fetched, name) + require.Equal(t, ws.ID, fetched.ID) +} + +func TestWorkspaceCreateWithLabels(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + name := uniqueName("ws-lbl") + ws, err := client.Workspaces().Create(ctx, name, map[string]string{"env": "test", "team": "infra"}) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + require.NoError(t, client.Workspaces().Delete(cleanupCtx, name)) + }) + require.Equal(t, "test", ws.Labels["env"]) + require.Equal(t, "infra", ws.Labels["team"]) + requireWorkspaceResponse(t, ws, name) + + fetched, err := client.Workspaces().Get(ctx, name) + require.NoError(t, err) + require.Equal(t, "test", fetched.Labels["env"]) + require.Equal(t, "infra", fetched.Labels["team"]) + requireWorkspaceResponse(t, fetched, name) + require.Equal(t, ws.ID, fetched.ID) +} + +func TestWorkspaceListIncludesCreated(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + name := uniqueName("ws-list") + _, err := client.Workspaces().Create(ctx, name, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + require.NoError(t, client.Workspaces().Delete(cleanupCtx, name)) + }) + + workspaces, err := client.Workspaces().ListAll(ctx) + require.NoError(t, err) + + found := false + hasDefault := false + for _, ws := range workspaces { + if ws.Name == name { + found = true + } + if ws.Name == "default" { + hasDefault = true + } + } + require.True(t, found, "expected created workspace %q in list", name) + require.True(t, hasDefault, "expected \"default\" workspace in list") +} + +func TestWorkspaceDeleteNonexistentNotFound(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + err := client.Workspaces().Delete(ctx, uniqueName("no-such-ws")) + require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err) +} + +func TestWorkspaceGetNonexistentNotFound(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, err := client.Workspaces().Get(ctx, uniqueName("no-such-ws")) + require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err) +} diff --git a/mise.lock b/mise.lock index a5e38be2c2..e506f1b16e 100644 --- a/mise.lock +++ b/mise.lock @@ -119,11 +119,6 @@ checksum = "sha256:f73a5c39f96bb6ebb89cc7915cf182260d4cbf30765322c5e793d0fe8bd80 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"."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" diff --git a/tasks/test.toml b/tasks/test.toml index 8c41e70025..6a0ef2af6a 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -61,8 +61,8 @@ run = "uv run --no-project --with pytest pytest -o \"python_files=*_test.py\" ta hide = true [e2e] -description = "Run all end-to-end tests (Rust + Python + MCP)" -depends = ["e2e:rust", "e2e:python", "e2e:mcp"] +description = "Run all end-to-end tests (Rust + Python + Go + MCP)" +depends = ["e2e:rust", "e2e:python", "e2e:go", "e2e:mcp"] ["e2e:test"] description = "Build the current checkout and run a named host or Nix test-guest E2E suite" @@ -134,6 +134,12 @@ depends = ["python:proto"] env = { UV_NO_SYNC = "1", PYTHONPATH = "python" } run = "e2e/with-docker-gateway.sh uv run pytest -o python_files='test_*.py *_test.py' -m 'not gpu' -n ${E2E_PARALLEL:-5} e2e/python" +["e2e:go"] +description = "Run Go SDK e2e tests against a Podman-backed gateway" +run = [ + "e2e/with-podman-gateway.sh bash -c 'cd e2e/go && go vet -tags=e2e ./... && go test -tags=e2e -parallel ${E2E_PARALLEL:-5} ./...'", +] + ["e2e:podman"] description = "Run Rust CLI e2e tests against a Podman-backed gateway" depends = ["e2e:conformance:build"]