From 00cd9ae9f94c5b9f2aaf1705ce51ad1d36b20041 Mon Sep 17 00:00:00 2001 From: Jiri Petrlik Date: Mon, 17 Aug 2026 17:33:36 +0200 Subject: [PATCH] test: E2E tests for Go SDK Signed-off-by: Jiri Petrlik --- .github/workflows/e2e-docker-test.yml | 2 +- AGENTS.md | 1 + e2e/go/go.mod | 24 ++ e2e/go/go.sum | 52 +++++ e2e/go/harness_test.go | 279 +++++++++++++++++++++++ e2e/go/providers_test.go | 308 +++++++++++++++++++++++++ e2e/go/sandbox_lifecycle_test.go | 314 ++++++++++++++++++++++++++ e2e/go/workspace_test.go | 137 +++++++++++ tasks/test.toml | 18 +- 9 files changed, 1131 insertions(+), 4 deletions(-) create mode 100644 e2e/go/go.mod create mode 100644 e2e/go/go.sum create mode 100644 e2e/go/harness_test.go create mode 100644 e2e/go/providers_test.go create mode 100644 e2e/go/sandbox_lifecycle_test.go create mode 100644 e2e/go/workspace_test.go diff --git a/.github/workflows/e2e-docker-test.yml b/.github/workflows/e2e-docker-test.yml index 24eb4a54f7..883c1f07b2 100644 --- a/.github/workflows/e2e-docker-test.yml +++ b/.github/workflows/e2e-docker-test.yml @@ -33,7 +33,7 @@ on: required: false type: string default: >- - [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}] + [{"suite":"python","cmd":"mise run --no-deps --skip-deps e2e:python","apt_packages":"","python_proto":true,"mcp":false},{"suite":"go","cmd":"mise run --no-deps --skip-deps e2e:go:docker","apt_packages":"","python_proto":false,"mcp":false},{"suite":"oidc-python","cmd":"mise run --no-deps --skip-deps e2e:oidc-python:docker","apt_packages":"","python_proto":true,"mcp":false},{"suite":"oidc-pkce-docker","cmd":"mise run --no-deps --skip-deps e2e:oidc-pkce:docker","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"rust-docker","cmd":"mise run --no-deps --skip-deps e2e:rust","apt_packages":"openssh-client","python_proto":false,"mcp":false},{"suite":"mcp","cmd":"mise run --no-deps --skip-deps e2e:mcp","apt_packages":"","python_proto":false,"mcp":true}] permissions: actions: read diff --git a/AGENTS.md b/AGENTS.md index b049c28f50..f31b97c03f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -187,6 +187,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` with Podman or `mise run e2e:go:docker` with Docker. ## 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..42694a22af --- /dev/null +++ b/e2e/go/harness_test.go @@ -0,0 +1,279 @@ +// 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 with `mise run e2e:go` (Podman) or +// `mise run e2e:go:docker` (Docker). +package e2e + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" + + 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, cancel := context.WithTimeout(context.Background(), 3*time.Minute) + defer cancel() + + 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) +} + +func requireSandboxResponse(t *testing.T, sandbox *v1.Sandbox, workspace, name string) { + t.Helper() + require.NotNil(t, sandbox) + require.NotEmpty(t, sandbox.ID) + require.Equal(t, workspace, sandbox.Workspace) + require.Equal(t, name, sandbox.Name) + require.False(t, sandbox.CreatedAt.IsZero()) + require.NotZero(t, sandbox.ResourceVersion) +} + +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) { + command := []string{"sh", "-c", `if value=$(printenv "$1"); then printf "%s" "$value"; else printf NOT_SET; fi`, "--", key} + result, err := client.Exec().Run(ctx, workspace, sandboxName, command, v1.ExecOptions{}) + // A supervisor reconnect can make one reverse relay miss its opening window. + // Retry only that transport timeout; repeated failures still fail the test. + if err != nil && ctx.Err() == nil && v1.IsDeadlineExceeded(err) && strings.Contains(err.Error(), "relay open timed out") { + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(250 * time.Millisecond): + } + result, err = client.Exec().Run(ctx, workspace, sandboxName, command, v1.ExecOptions{}) + } + if err != nil { + return "", err + } + if result.ExitCode != 0 { + return "", fmt.Errorf("exec exited %d: %s", result.ExitCode, string(result.Stderr)) + } + if len(result.Stderr) != 0 { + return "", fmt.Errorf("exec wrote unexpected stderr: %s", string(result.Stderr)) + } + return string(result.Stdout), nil +} + +func isPlaceholderForEnvKey(value, key string) bool { + const prefix = "openshell:resolve:env:" + suffix, ok := strings.CutPrefix(value, prefix) + if !ok { + return false + } + if suffix == key { + return true + } + + discriminator, placeholderKey, ok := strings.Cut(suffix, "_") + if !ok || placeholderKey != key || len(discriminator) < 2 { + return false + } + + switch discriminator[0] { + case 'v': + for _, char := range discriminator[1:] { + if char < '0' || char > '9' { + return false + } + } + return true + case 's': + if len(discriminator) != 65 { + return false + } + for _, char := range discriminator[1:] { + if !((char >= '0' && char <= '9') || (char >= 'a' && char <= 'f')) { + return false + } + } + return true + default: + return false + } +} + +func TestIsPlaceholderForEnvKey(t *testing.T) { + stableHandle := strings.Repeat("a1", 32) + tests := []struct { + name string + value string + key string + want bool + }{ + {name: "plain", value: "openshell:resolve:env:API_KEY", key: "API_KEY", want: true}, + {name: "revisioned", value: "openshell:resolve:env:v12_API_KEY", key: "API_KEY", want: true}, + {name: "stable handle", value: "openshell:resolve:env:s" + stableHandle + "_API_KEY", key: "API_KEY", want: true}, + {name: "overlapping key suffix", value: "openshell:resolve:env:v12_LONG_API_KEY", key: "API_KEY", want: false}, + {name: "revision requires digits", value: "openshell:resolve:env:vnext_API_KEY", key: "API_KEY", want: false}, + {name: "stable handle requires lowercase hex", value: "openshell:resolve:env:s" + strings.ToUpper(stableHandle) + "_API_KEY", key: "API_KEY", want: false}, + {name: "stable handle requires 64 characters", value: "openshell:resolve:env:sabc_API_KEY", key: "API_KEY", want: false}, + {name: "wrong key", value: "openshell:resolve:env:v12_OTHER_KEY", key: "API_KEY", want: false}, + {name: "raw secret", value: "secret", key: "API_KEY", want: false}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + require.Equal(t, test.want, isPlaceholderForEnvKey(test.value, test.key)) + }) + } +} + +// pollUntil calls fetch until the predicate is satisfied, the local polling +// timeout expires, or ctx is done, whichever happens first. +func pollUntil[T any](t *testing.T, ctx context.Context, fetch func() T, predicate func(T) bool) T { + t.Helper() + deadline := time.NewTimer(35 * time.Second) + defer deadline.Stop() + ticker := time.NewTicker(time.Second) + defer ticker.Stop() + + for { + value := fetch() + if predicate(value) { + return value + } + select { + case <-ctx.Done(): + t.Fatalf("context done while polling; last observed %v: %v", value, ctx.Err()) + case <-deadline.C: + t.Fatalf("timed out waiting for expected value, last observed %v", value) + case <-ticker.C: + } + } +} + +// 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", "/lib64", "/proc", "/etc", "/app", "/dev/urandom"}, + ReadWrite: []string{"/sandbox", "/tmp", "/dev/null"}, + }, + Landlock: &v1.LandlockPolicy{Compatibility: "best_effort"}, + Process: &v1.ProcessPolicy{RunAsUser: "10001", RunAsGroup: "10001"}, + } +} diff --git a/e2e/go/providers_test.go b/e2e/go/providers_test.go new file mode 100644 index 0000000000..4e41aa8a51 --- /dev/null +++ b/e2e/go/providers_test.go @@ -0,0 +1,308 @@ +// 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" + "testing" + "time" + + "github.com/stretchr/testify/require" + + v1 "github.com/NVIDIA/OpenShell/sdk/go/openshell/v1" +) + +// 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 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() + const maxAttempts = 50 + for attempt := 1; attempt <= maxAttempts; attempt++ { + 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) + } + if attempt%10 == 0 { + t.Logf("attach provider still conflicted after %d attempts: %v", attempt, err) + } + if attempt == maxAttempts { + t.Fatalf("attach provider still conflicted after %d attempts: %v", attempt, err) + } + select { + case <-ctx.Done(): + t.Fatalf("context done while retrying attach provider: %v", ctx.Err()) + case <-time.After(200 * time.Millisecond): + } + } + return nil +} + +// detachProviderRetry mirrors attachProviderRetry for detach. +func detachProviderRetry(ctx context.Context, t *testing.T, client *v1.Client, workspace, sandboxName, providerName string) *v1.DetachProviderResult { + t.Helper() + const maxAttempts = 50 + for attempt := 1; attempt <= maxAttempts; attempt++ { + 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) + } + if attempt%10 == 0 { + t.Logf("detach provider still conflicted after %d attempts: %v", attempt, err) + } + if attempt == maxAttempts { + t.Fatalf("detach provider still conflicted after %d attempts: %v", attempt, err) + } + select { + case <-ctx.Done(): + t.Fatalf("context done while retrying detach provider: %v", ctx.Err()) + case <-time.After(200 * time.Millisecond): + } + } + return nil +} + +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-code", + 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}, + Command: []string{"sleep", "infinity"}, + }, nil) + require.NoError(t, err) + requireSandboxResponse(t, sandbox, "default", sandboxName) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + detachProviderBestEffort(cleanupCtx, client, "default", sandboxName, providerName) + _, err := client.Sandboxes().Delete(cleanupCtx, "default", sandboxName) + require.NoError(t, err) + }) + + 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(), + Command: []string{"sleep", "infinity"}, + }, nil) + require.NoError(t, err) + requireSandboxResponse(t, sandbox, "default", sandboxName) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cleanupCancel() + detachProviderBestEffort(cleanupCtx, client, "default", sandboxName, providerName) + _, err := client.Sandboxes().Delete(cleanupCtx, "default", sandboxName) + require.NoError(t, err) + }) + + 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) + requireSandboxResponse(t, attachResult.Sandbox, "default", sandboxName) + require.Equal(t, sandbox.ID, attachResult.Sandbox.ID) + require.Contains(t, attachResult.Sandbox.Spec.Providers, providerName) + + 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) + requireSandboxResponse(t, detachResult.Sandbox, "default", sandboxName) + require.Equal(t, sandbox.ID, detachResult.Sandbox.ID) + require.NotContains(t, detachResult.Sandbox.Spec.Providers, providerName) + + 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-code", + 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) +} diff --git a/e2e/go/sandbox_lifecycle_test.go b/e2e/go/sandbox_lifecycle_test.go new file mode 100644 index 0000000000..3bdd801c1d --- /dev/null +++ b/e2e/go/sandbox_lifecycle_test.go @@ -0,0 +1,314 @@ +// 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" + "slices" + "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") + policy := defaultPolicy() + sb, err := client.Sandboxes().Create(ctx, "default", name, &v1.SandboxSpec{ + Policy: policy, + Command: []string{"sleep", "infinity"}, + }, nil) + require.NoError(t, err) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cleanupCancel() + _, err := client.Sandboxes().Delete(cleanupCtx, "default", name) + require.NoError(t, err) + }) + requireSandboxResponse(t, sb, "default", name) + require.Equal(t, policy, sb.Spec.Policy) + + 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) + requireSandboxResponse(t, fetched, "default", name) + require.Equal(t, sb.ID, fetched.ID) + require.Equal(t, policy, fetched.Spec.Policy) + + 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)) + require.Empty(t, result.Stderr) + + failedResult, err := client.Exec().Run(ctx, "default", name, + []string{"sh", "-c", "printf exec-failed >&2; exit 42"}, v1.ExecOptions{}) + require.NoError(t, err) + require.Equal(t, 42, failedResult.ExitCode) + require.Empty(t, failedResult.Stdout) + require.Contains(t, string(failedResult.Stderr), "exec-failed") + + // 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) + require.Empty(t, writeResult.Stderr) + + 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)) + require.Empty(t, readResult.Stderr) +} + +func TestSandboxGetNonexistentNotFound(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, err := client.Sandboxes().Get(ctx, "default", uniqueName("no-such-sb")) + require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err) +} + +func TestSandboxDeleteNonexistentNotFound(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + _, err := client.Sandboxes().Delete(ctx, "default", uniqueName("no-such-sb")) + require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err) +} + +func TestSandboxCreateDuplicateAlreadyExists(t *testing.T) { + client := requireClient(t) + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + name := uniqueName("sb-dup") + sandbox, err := client.Sandboxes().Create(ctx, "default", name, &v1.SandboxSpec{}, nil) + require.NoError(t, err) + requireSandboxResponse(t, sandbox, "default", name) + t.Cleanup(func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cleanupCancel() + _, err := client.Sandboxes().Delete(cleanupCtx, "default", name) + require.NoError(t, err) + }) + + _, err = client.Sandboxes().Create(ctx, "default", name, &v1.SandboxSpec{}, nil) + require.True(t, v1.IsAlreadyExists(err), "expected AlreadyExists, got %v", err) +} + +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) + requireSandboxResponse(t, defaultSandbox, "default", defaultName) + 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) + requireSandboxResponse(t, otherSandbox, otherWorkspace, otherName) + 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) + requireSandboxResponse(t, refA, "default", jobA) + 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) + }) + + refB, err := client.Sandboxes().Create(ctx, "default", jobB, &v1.SandboxSpec{}, + map[string]string{"aiq-test": suffix, "role": "secondary"}) + require.NoError(t, err) + requireSandboxResponse(t, refB, "default", jobB) + 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) + requireSandboxResponse(t, fetchedA, "default", jobA) + require.Equal(t, "primary", fetchedA.Labels["role"]) + + fetchedB, err := client.Sandboxes().Get(ctx, "default", jobB) + require.NoError(t, err) + requireSandboxResponse(t, fetchedB, "default", jobB) + 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)) + + _, err = client.Sandboxes().Delete(ctx, "default", jobA) + require.NoError(t, err) + deletedA = true + + // Sandbox deletion becomes list-visible asynchronously, so poll until the + // compute driver's deletion event drains through the background watcher. + remaining := pollUntil(t, ctx, func() []string { + list, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: groupSelector}) + require.NoError(t, err) + return names(list) + }, func(values []string) bool { return elementsMatch(values, []string{jobB}) }) + require.ElementsMatch(t, []string{jobB}, remaining) + + _, err = client.Sandboxes().Delete(ctx, "default", jobB) + require.NoError(t, err) + deletedB = true + + empty := pollUntil(t, ctx, func() []string { + list, err := client.Sandboxes().ListAll(ctx, "default", v1.ListOptions{LabelSelector: groupSelector}) + require.NoError(t, err) + return names(list) + }, func(values []string) bool { return len(values) == 0 }) + require.Empty(t, empty) +} + +func elementsMatch(a, b []string) bool { + return slices.Equal(slices.Sorted(slices.Values(a)), slices.Sorted(slices.Values(b))) +} + +// 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..b2abdc62c9 --- /dev/null +++ b/e2e/go/workspace_test.go @@ -0,0 +1,137 @@ +// 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() + _, err := client.Workspaces().Delete(cleanupCtx, name) + require.NoError(t, err) + }) + 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() + _, err := client.Workspaces().Delete(cleanupCtx, name) + require.NoError(t, err) + }) + 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() + _, err := client.Workspaces().Delete(cleanupCtx, name) + require.NoError(t, err) + }) + + 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-ws")) + require.True(t, v1.IsNotFound(err), "expected NotFound, got %v", err) +} diff --git a/tasks/test.toml b/tasks/test.toml index 8a90f3c084..ec3d845a4d 100644 --- a/tasks/test.toml +++ b/tasks/test.toml @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -# Test tasks (Rust + Python + TypeScript SDK) +# Test tasks [test] description = "Run all tests (Rust + Python + TypeScript SDK)" @@ -79,8 +79,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:docker", "e2e:mcp"] ["e2e:test"] description = "Build the current checkout and run a named host or Nix test-guest E2E suite" @@ -161,6 +161,18 @@ run = [ "OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE=openshell/e2e-python:dev OPENSHELL_E2E_DOCKER_SANDBOX_IMAGE_PULL_POLICY=never 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 -timeout 8m -parallel ${E2E_PARALLEL:-5} ./...'", +] + +["e2e:go:docker"] +description = "Run Go SDK e2e tests against a Docker-backed gateway" +run = [ + "e2e/with-docker-gateway.sh bash -c 'cd e2e/go && go vet -tags=e2e ./... && go test -tags=e2e -timeout 8m -parallel ${E2E_PARALLEL:-5} ./...'", +] + ["e2e:podman"] description = "Run Rust CLI e2e tests against a Podman-backed gateway" depends = ["e2e:conformance:build"]