diff --git a/sdk/go/README.md b/sdk/go/README.md new file mode 100644 index 00000000..fb402c2e --- /dev/null +++ b/sdk/go/README.md @@ -0,0 +1,159 @@ +# cubesandbox Go SDK + +Go SDK for [CubeSandbox](https://github.com/TencentCloud/CubeSandbox). It matches the current Python SDK surface: sandbox lifecycle, code execution, commands, and file reads only. + +## Install + +```bash +go get github.com/tencentcloud/CubeSandbox/sdk/go +``` + +## Configuration + +```bash +export CUBE_API_URL=http://127.0.0.1:3000 +export CUBE_TEMPLATE_ID= + +# Optional remote data-plane access. +export CUBE_PROXY_NODE_IP= +export CUBE_PROXY_PORT_HTTP=80 +export CUBE_PROXY_SCHEME=http +export CUBE_SANDBOX_DOMAIN=cube.app +``` + +`NewConfigFromEnv` also accepts `E2B_API_URL` and `E2B_API_KEY`; `CUBE_API_URL` and `CUBE_API_KEY` take precedence. +`CUBE_PROXY_SCHEME` supports `http` and `https`; when omitted, port `443` defaults to `https` and other ports default to `http`. + +## Create And Run Code + +```go +package main + +import ( + "context" + "fmt" + + cubesandbox "github.com/tencentcloud/CubeSandbox/sdk/go" +) + +func main() { + ctx := context.Background() + client := cubesandbox.NewClient(cubesandbox.NewConfigFromEnv()) + + sb, err := client.Create(ctx, cubesandbox.CreateOptions{}) + if err != nil { + panic(err) + } + defer sb.Kill(ctx) + + exec, err := sb.RunCode(ctx, "x = 41\nx + 1", cubesandbox.RunCodeOptions{}) + if err != nil { + panic(err) + } + fmt.Println(exec.Text) +} +``` + +## Commands + +```go +result, err := sb.Commands().Run(ctx, "echo hello", cubesandbox.CommandOptions{}) +if err != nil { + panic(err) +} +fmt.Println(result.Stdout, result.Stderr, result.ExitCode) +``` + +`Commands.Run` starts `/bin/bash -l -c ` through envd's `process.Process/Start` API and returns stdout, stderr, and the `EndEvent` exit code. Callers are still responsible for treating untrusted shell input carefully. + +## Files + +```go +content, err := sb.Files().Read(ctx, "/etc/hosts") +``` + +`Files.Read` downloads content through envd's `GET /files?path=...` file API. + +## Pause And Connect + +```go +wait := true +if err := sb.Pause(ctx, cubesandbox.PauseOptions{Wait: &wait}); err != nil { + panic(err) +} + +resumed, err := client.Connect(ctx, sb.SandboxID) +if err != nil { + panic(err) +} +_ = resumed +``` + +`Sandbox.Resume` is available for compatibility but deprecated; prefer `Client.Connect`. + +## Network Policy + +```go +denyInternet := false +sb, err := client.Create(ctx, cubesandbox.CreateOptions{ + AllowInternetAccess: &denyInternet, + Network: cubesandbox.NetworkOptions{ + AllowOut: []string{"151.101.0.0/16"}, + DenyOut: []string{"0.0.0.0/0"}, + }, +}) +``` + +## Host Directory Mount + +```go +sb, err := client.Create(ctx, cubesandbox.CreateOptions{ + Metadata: map[string]string{ + "hostdir-mount": `[{"hostPath":"/data/shared","mountPath":"/mnt/data"}]`, + }, +}) +``` + +## Remote Proxy + +When `CUBE_PROXY_NODE_IP` is set, data-plane requests connect directly to that IP and port while preserving the virtual sandbox host: + +```text +URL: ://49999-./ +TCP: : +Host: 49999-. +``` + +You can also set it directly: + +```go +cfg := cubesandbox.Config{ + APIURL: "http://10.0.0.1:3000", + TemplateID: "tpl-xxxxxxxx", + ProxyNodeIP: "10.0.0.1", + ProxyPortHTTP: 80, + ProxyScheme: "http", + SandboxDomain: "cube.app", +} +client := cubesandbox.NewClient(cfg) +``` + +## Integration Tests + +Unit tests do not require a live service: + +```bash +go test ./... +``` + +Live integration tests are behind the `integration` build tag. They require `CUBE_API_URL`, auto-discover a READY template from `/templates` when `CUBE_TEMPLATE_ID` is unset, and use `CUBE_PROXY_NODE_IP` for remote data-plane proxying when needed. + +```bash +export CUBE_API_URL=http://:3000 +export CUBE_TEMPLATE_ID= +export CUBE_PROXY_NODE_IP= +export CUBE_PROXY_PORT_HTTP=80 +export CUBE_PROXY_SCHEME=http +export CUBE_SANDBOX_DOMAIN=cube.app +go test -tags=integration -run Integration -count=1 ./... +``` diff --git a/sdk/go/client.go b/sdk/go/client.go new file mode 100644 index 00000000..f36007a7 --- /dev/null +++ b/sdk/go/client.go @@ -0,0 +1,204 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +type ClientOption func(*Client) + +// WithHTTPClient injects an HTTP client for SDK requests. It is primarily +// useful in tests or when the caller owns transport configuration. +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(c *Client) { + if httpClient == nil { + return + } + c.controlHTTP = httpClient + c.dataHTTP = httpClient + } +} + +type Client struct { + config Config + controlHTTP *http.Client + dataHTTP *http.Client +} + +func NewClient(config Config, opts ...ClientOption) *Client { + config = normalizeConfig(config) + client := &Client{ + config: config, + controlHTTP: newControlHTTPClient(config), + dataHTTP: newDataHTTPClient(config), + } + for _, opt := range opts { + opt(client) + } + return client +} + +func (c *Client) Create(ctx context.Context, opts CreateOptions) (*Sandbox, error) { + payload, err := c.createPayload(opts) + if err != nil { + return nil, err + } + + var sandbox Sandbox + if err := c.doJSON(ctx, http.MethodPost, "/sandboxes", payload, &sandbox, http.StatusOK, http.StatusCreated); err != nil { + return nil, err + } + c.attachSandbox(&sandbox) + return &sandbox, nil +} + +func (c *Client) Connect(ctx context.Context, sandboxID string) (*Sandbox, error) { + payload := map[string]any{"timeout": durationSeconds(c.config.Timeout)} + var sandbox Sandbox + if err := c.doJSON(ctx, http.MethodPost, "/sandboxes/"+url.PathEscape(sandboxID)+"/connect", payload, &sandbox, http.StatusOK); err != nil { + return nil, err + } + c.attachSandbox(&sandbox) + return &sandbox, nil +} + +func (c *Client) List(ctx context.Context) ([]SandboxInfo, error) { + var sandboxes []SandboxInfo + if err := c.doJSON(ctx, http.MethodGet, "/sandboxes", nil, &sandboxes, http.StatusOK); err != nil { + return nil, err + } + return sandboxes, nil +} + +func (c *Client) ListV2(ctx context.Context) ([]SandboxInfo, error) { + var sandboxes []SandboxInfo + if err := c.doJSON(ctx, http.MethodGet, "/v2/sandboxes", nil, &sandboxes, http.StatusOK); err != nil { + return nil, err + } + return sandboxes, nil +} + +func (c *Client) Health(ctx context.Context) (map[string]any, error) { + var health map[string]any + if err := c.doJSON(ctx, http.MethodGet, "/health", nil, &health, http.StatusOK); err != nil { + return nil, err + } + return health, nil +} + +func (c *Client) createPayload(opts CreateOptions) (map[string]any, error) { + templateID := opts.TemplateID + if templateID == "" { + templateID = c.config.TemplateID + } + if templateID == "" { + return nil, fmt.Errorf("template is required. Set CUBE_TEMPLATE_ID or pass TemplateID") + } + + timeout := opts.Timeout + if timeout <= 0 { + timeout = c.config.Timeout + } + payload := map[string]any{ + "templateID": templateID, + "timeout": durationSeconds(timeout), + } + if len(opts.EnvVars) > 0 { + payload["envVars"] = opts.EnvVars + } + if len(opts.Metadata) > 0 { + payload["metadata"] = opts.Metadata + } + if opts.AllowInternetAccess != nil && !*opts.AllowInternetAccess { + payload["allowInternetAccess"] = false + } + + network := map[string]any{} + if len(opts.Network.AllowOut) > 0 { + network["allowOut"] = opts.Network.AllowOut + } + if len(opts.Network.DenyOut) > 0 { + network["denyOut"] = opts.Network.DenyOut + } + if len(network) > 0 { + payload["network"] = network + } + + for key, value := range opts.Extra { + payload[key] = value + } + + return payload, nil +} + +func (c *Client) attachSandbox(sandbox *Sandbox) { + sandbox.client = c + if sandbox.Domain == "" { + sandbox.Domain = c.config.SandboxDomain + } +} + +func (c *Client) doJSON(ctx context.Context, method, path string, body any, out any, okStatuses ...int) error { + req, err := c.newRequest(ctx, method, path, body) + if err != nil { + return err + } + + resp, err := c.controlHTTP.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if !statusOK(resp.StatusCode, okStatuses) { + return apiErrorFromResponse(resp) + } + if out == nil || resp.StatusCode == http.StatusNoContent { + io.Copy(io.Discard, resp.Body) + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return err + } + return nil +} + +func (c *Client) newRequest(ctx context.Context, method, path string, body any) (*http.Request, error) { + var reader io.Reader + if body != nil { + raw, err := json.Marshal(body) + if err != nil { + return nil, err + } + reader = bytes.NewReader(raw) + } + + req, err := http.NewRequestWithContext(ctx, method, c.config.APIURL+path, reader) + if err != nil { + return nil, err + } + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + if c.config.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+c.config.APIKey) + } + return req, nil +} + +func statusOK(statusCode int, okStatuses []int) bool { + for _, ok := range okStatuses { + if statusCode == ok { + return true + } + } + return false +} diff --git a/sdk/go/commands.go b/sdk/go/commands.go new file mode 100644 index 00000000..a20d56a1 --- /dev/null +++ b/sdk/go/commands.go @@ -0,0 +1,47 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "context" + "fmt" +) + +type processStarter interface { + startProcess(context.Context, processStartRequest, CommandOptions) (*processStartResult, error) +} + +type Commands struct { + starter processStarter +} + +func (c *Commands) Run(ctx context.Context, cmd string, opts CommandOptions) (*CommandResult, error) { + if c == nil || c.starter == nil { + return nil, fmt.Errorf("commands is not attached to a sandbox") + } + + envs := opts.Envs + if envs == nil { + envs = map[string]string{} + } + stdin := false + process, err := c.starter.startProcess(ctx, processStartRequest{ + Process: processConfig{ + Cmd: "/bin/bash", + Args: []string{"-l", "-c", cmd}, + Envs: envs, + Cwd: opts.Cwd, + }, + Stdin: &stdin, + }, opts) + if err != nil { + return nil, err + } + + return &CommandResult{ + Stdout: process.Stdout, + Stderr: process.Stderr, + ExitCode: process.ExitCode, + }, nil +} diff --git a/sdk/go/config.go b/sdk/go/config.go new file mode 100644 index 00000000..a15dec96 --- /dev/null +++ b/sdk/go/config.go @@ -0,0 +1,132 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "os" + "strconv" + "strings" + "time" +) + +const ( + defaultAPIURL = "http://127.0.0.1:3000" + defaultProxyPortHTTP = 80 + defaultSandboxDomain = "cube.app" + defaultSandboxTimeout = 300 * time.Second + defaultRequestTimeout = 30 * time.Second +) + +// Config holds SDK configuration for control-plane and data-plane requests. +type Config struct { + APIURL string + APIKey string + TemplateID string + ProxyNodeIP string + ProxyPortHTTP int + ProxyScheme string + SandboxDomain string + Timeout time.Duration + RequestTimeout time.Duration +} + +// NewConfigFromEnv builds Config from environment variables. +// +// CUBE_API_URL and CUBE_API_KEY take precedence over E2B_API_URL and +// E2B_API_KEY for compatibility with existing E2B-style deployments. +func NewConfigFromEnv() Config { + cfg := Config{ + APIURL: firstEnv("CUBE_API_URL", "E2B_API_URL"), + APIKey: firstEnv("CUBE_API_KEY", "E2B_API_KEY"), + TemplateID: strings.TrimSpace(os.Getenv("CUBE_TEMPLATE_ID")), + ProxyNodeIP: strings.TrimSpace(os.Getenv("CUBE_PROXY_NODE_IP")), + ProxyPortHTTP: parseIntEnv("CUBE_PROXY_PORT_HTTP", defaultProxyPortHTTP), + ProxyScheme: strings.TrimSpace(os.Getenv("CUBE_PROXY_SCHEME")), + SandboxDomain: strings.TrimSpace(os.Getenv("CUBE_SANDBOX_DOMAIN")), + Timeout: parseDurationEnv("CUBE_TIMEOUT", defaultSandboxTimeout), + RequestTimeout: parseDurationEnv("CUBE_REQUEST_TIMEOUT", defaultRequestTimeout), + } + return normalizeConfig(cfg) +} + +func normalizeConfig(cfg Config) Config { + cfg.APIURL = strings.TrimRight(strings.TrimSpace(cfg.APIURL), "/") + if cfg.APIURL == "" { + cfg.APIURL = defaultAPIURL + } + cfg.SandboxDomain = strings.TrimSpace(cfg.SandboxDomain) + if cfg.SandboxDomain == "" { + cfg.SandboxDomain = defaultSandboxDomain + } + if cfg.ProxyPortHTTP <= 0 { + cfg.ProxyPortHTTP = defaultProxyPortHTTP + } + cfg.ProxyScheme = normalizeProxyScheme(cfg.ProxyScheme, cfg.ProxyPortHTTP) + if cfg.Timeout <= 0 { + cfg.Timeout = defaultSandboxTimeout + } + if cfg.RequestTimeout <= 0 { + cfg.RequestTimeout = defaultRequestTimeout + } + return cfg +} + +func normalizeProxyScheme(scheme string, port int) string { + normalized := strings.ToLower(strings.TrimSpace(scheme)) + switch normalized { + case "http", "https": + return normalized + } + if port == 443 { + return "https" + } + return "http" +} + +func firstEnv(names ...string) string { + for _, name := range names { + if value := strings.TrimSpace(os.Getenv(name)); value != "" { + return value + } + } + return "" +} + +func parseIntEnv(name string, fallback int) int { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + parsed, err := strconv.Atoi(value) + if err != nil || parsed <= 0 { + return fallback + } + return parsed +} + +func parseDurationEnv(name string, fallback time.Duration) time.Duration { + value := strings.TrimSpace(os.Getenv(name)) + if value == "" { + return fallback + } + if parsed, err := time.ParseDuration(value); err == nil && parsed > 0 { + return parsed + } + seconds, err := strconv.ParseFloat(value, 64) + if err != nil || seconds <= 0 { + return fallback + } + return time.Duration(seconds * float64(time.Second)) +} + +func durationSeconds(d time.Duration) int { + if d <= 0 { + return 0 + } + seconds := int(d / time.Second) + if d%time.Second != 0 { + seconds++ + } + return seconds +} diff --git a/sdk/go/coverage_test.go b/sdk/go/coverage_test.go new file mode 100644 index 00000000..eb80212c --- /dev/null +++ b/sdk/go/coverage_test.go @@ -0,0 +1,583 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestWithHTTPClientOption(t *testing.T) { + custom := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(`{"status":"ok"}`)), + Request: req, + }, nil + })} + + client := NewClient(Config{}, WithHTTPClient(nil), WithHTTPClient(custom)) + if client.controlHTTP != custom || client.dataHTTP != custom { + t.Fatalf("custom client was not installed") + } + + health, err := client.Health(context.Background()) + if err != nil { + t.Fatalf("Health returned error: %v", err) + } + if health["status"] != "ok" { + t.Fatalf("health=%#v", health) + } +} + +func TestClientRequestErrorPaths(t *testing.T) { + client := NewClient(Config{APIURL: "http://127.0.0.1:1"}) + + if err := client.doJSON(context.Background(), http.MethodPost, "/x", map[string]any{"bad": func() {}}, nil, http.StatusOK); err == nil { + t.Fatal("doJSON with unmarshalable body returned nil error") + } + + client.config.APIURL = "http://%" + if err := client.doJSON(context.Background(), http.MethodGet, "/x", nil, nil, http.StatusOK); err == nil { + t.Fatal("doJSON with malformed URL returned nil error") + } + + boom := errors.New("boom") + client = NewClient(Config{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, boom + })})) + if err := client.doJSON(context.Background(), http.MethodGet, "/x", nil, nil, http.StatusOK); !errors.Is(err, boom) { + t.Fatalf("doJSON transport error=%v, want %v", err, boom) + } + + client = NewClient(Config{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("{")), + Request: req, + }, nil + })})) + var out map[string]any + if err := client.doJSON(context.Background(), http.MethodGet, "/x", nil, &out, http.StatusOK); err == nil { + t.Fatal("doJSON with malformed JSON response returned nil error") + } +} + +func TestListErrorPathsAndAttachDomain(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/sandboxes": + http.Error(w, `{"message":"list failed"}`, http.StatusInternalServerError) + case "/v2/sandboxes": + http.Error(w, `{"message":"list v2 failed"}`, http.StatusInternalServerError) + default: + t.Fatalf("unexpected path %s", r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(Config{APIURL: server.URL, SandboxDomain: "domain.test"}) + if _, err := client.List(context.Background()); err == nil { + t.Fatal("List returned nil error") + } + if _, err := client.ListV2(context.Background()); err == nil { + t.Fatal("ListV2 returned nil error") + } + + sb := Sandbox{SandboxID: "sb-domain"} + client.attachSandbox(&sb) + if sb.Domain != "domain.test" { + t.Fatalf("Domain=%q", sb.Domain) + } + sb.Domain = "response.domain" + client.attachSandbox(&sb) + if sb.Domain != "response.domain" { + t.Fatalf("Domain was overwritten: %q", sb.Domain) + } +} + +func TestSandboxRequiresAttachedClient(t *testing.T) { + ctx := context.Background() + var nilSandbox *Sandbox + if err := nilSandbox.ensureClient(); err == nil { + t.Fatal("nil sandbox ensureClient returned nil error") + } + if _, err := (&Sandbox{}).GetInfo(ctx); err == nil { + t.Fatal("GetInfo without client returned nil error") + } + if err := (&Sandbox{}).Pause(ctx, PauseOptions{}); err == nil { + t.Fatal("Pause without client returned nil error") + } + if err := (&Sandbox{}).Resume(ctx, 0); err == nil { + t.Fatal("Resume without client returned nil error") + } + if err := (&Sandbox{}).Kill(ctx); err == nil { + t.Fatal("Kill without client returned nil error") + } + if _, err := (&Sandbox{}).RunCode(ctx, "1", RunCodeOptions{}); err == nil { + t.Fatal("RunCode without client returned nil error") + } +} + +func TestSandboxPauseBranches(t *testing.T) { + t.Run("post error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"sandbox not found"}`, http.StatusNotFound) + })) + defer server.Close() + + sb := &Sandbox{client: NewClient(Config{APIURL: server.URL}), SandboxID: "missing"} + if err := sb.Pause(context.Background(), PauseOptions{}); !errors.Is(err, ErrSandboxNotFound) { + t.Fatalf("Pause error=%v, want ErrSandboxNotFound", err) + } + }) + + t.Run("wait success with negative interval", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch { + case r.Method == http.MethodPost && strings.HasSuffix(r.URL.Path, "/pause"): + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodGet: + fmt.Fprint(w, sandboxInfoJSON("sb-pause", "paused")) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + sb := &Sandbox{client: NewClient(Config{APIURL: server.URL}), SandboxID: "sb-pause"} + if err := sb.Pause(context.Background(), PauseOptions{Timeout: 50 * time.Millisecond, Interval: -1}); err != nil { + t.Fatalf("Pause returned error: %v", err) + } + }) + + t.Run("default timeout with already paused sandbox", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusNoContent) + return + } + fmt.Fprint(w, sandboxInfoJSON("sb-default-timeout", "paused")) + })) + defer server.Close() + + sb := &Sandbox{client: NewClient(Config{APIURL: server.URL}), SandboxID: "sb-default-timeout"} + if err := sb.Pause(context.Background(), PauseOptions{}); err != nil { + t.Fatalf("Pause returned error: %v", err) + } + }) + + t.Run("get info error", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusNoContent) + return + } + http.Error(w, `{"message":"gone"}`, http.StatusNotFound) + })) + defer server.Close() + + sb := &Sandbox{client: NewClient(Config{APIURL: server.URL}), SandboxID: "sb-gone"} + if err := sb.Pause(context.Background(), PauseOptions{Timeout: time.Second}); !errors.Is(err, ErrSandboxNotFound) { + t.Fatalf("Pause error=%v, want ErrSandboxNotFound", err) + } + }) + + t.Run("timeout", func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + if r.Method == http.MethodPost { + w.WriteHeader(http.StatusNoContent) + return + } + fmt.Fprint(w, sandboxInfoJSON("sb-timeout", "running")) + })) + defer server.Close() + + sb := &Sandbox{client: NewClient(Config{APIURL: server.URL}), SandboxID: "sb-timeout"} + err := sb.Pause(context.Background(), PauseOptions{Timeout: time.Millisecond, Interval: time.Millisecond}) + if err == nil || !strings.Contains(err.Error(), "did not reach 'paused'") { + t.Fatalf("Pause timeout error=%v", err) + } + }) + + t.Run("context cancelled while waiting", func(t *testing.T) { + ctx, cancelFn := context.WithCancel(context.Background()) + roundTrips := 0 + client := NewClient(Config{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + roundTrips++ + if roundTrips == 1 { + return &http.Response{ + StatusCode: http.StatusNoContent, + Body: io.NopCloser(strings.NewReader("")), + Request: req, + }, nil + } + return &http.Response{ + StatusCode: http.StatusOK, + Body: cancelOnCloseBody{ + Reader: strings.NewReader(sandboxInfoJSON("sb-cancel", "running")), + cancel: cancelFn, + }, + Request: req, + }, nil + })})) + sb := &Sandbox{client: client, SandboxID: "sb-cancel"} + err := sb.Pause(ctx, PauseOptions{Timeout: time.Second, Interval: 20 * time.Millisecond}) + if !errors.Is(err, context.Canceled) { + t.Fatalf("Pause error=%v, want context.Canceled", err) + } + }) +} + +func TestResumeDefaultTimeoutAndErrors(t *testing.T) { + var gotBody string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + raw, _ := io.ReadAll(r.Body) + gotBody = string(raw) + w.WriteHeader(http.StatusNoContent) + })) + defer server.Close() + + sb := &Sandbox{ + client: NewClient(Config{APIURL: server.URL, Timeout: 123 * time.Second}), + SandboxID: "sb-resume", + } + if err := sb.Resume(context.Background(), 0); err != nil { + t.Fatalf("Resume returned error: %v", err) + } + if !strings.Contains(gotBody, `"timeout":123`) { + t.Fatalf("resume body=%s", gotBody) + } + + sb.client.config.APIURL = "http://%" + if err := sb.Resume(context.Background(), time.Second); err == nil { + t.Fatal("Resume with malformed URL returned nil error") + } +} + +func TestKillErrorPath(t *testing.T) { + sb := &Sandbox{ + client: NewClient(Config{APIURL: "http://%"}), + SandboxID: "sb-kill", + } + if err := sb.Kill(context.Background()); err == nil { + t.Fatal("Kill with malformed URL returned nil error") + } +} + +func TestCloseBranchesAndAccessors(t *testing.T) { + if err := (&Sandbox{}).Close(); err != nil { + t.Fatalf("Close without client returned error: %v", err) + } + sb := &Sandbox{client: NewClient(Config{})} + if err := sb.Close(); err != nil { + t.Fatalf("Close returned error: %v", err) + } + if sb.Commands() == nil { + t.Fatal("Commands returned nil") + } + if sb.Files() == nil { + t.Fatal("Files returned nil") + } +} + +func TestRunCodeErrorPaths(t *testing.T) { + t.Run("request build error", func(t *testing.T) { + sb := &Sandbox{ + client: NewClient(Config{}), + SandboxID: "sb-run", + Domain: "%", + } + if _, err := sb.RunCode(context.Background(), "1", RunCodeOptions{}); err == nil { + t.Fatal("RunCode with malformed URL returned nil error") + } + }) + + t.Run("transport error", func(t *testing.T) { + boom := errors.New("boom") + client := NewClient(Config{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, boom + })})) + sb := &Sandbox{client: client, SandboxID: "sb-run", Domain: "cube.test"} + if _, err := sb.RunCode(context.Background(), "1", RunCodeOptions{}); !errors.Is(err, boom) { + t.Fatalf("RunCode transport error=%v", err) + } + }) + + t.Run("http status error", func(t *testing.T) { + client := NewClient(Config{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusBadGateway, + Body: io.NopCloser(strings.NewReader("bad gateway")), + Request: req, + }, nil + })})) + sb := &Sandbox{client: client, SandboxID: "sb-run", Domain: "cube.test"} + _, err := sb.RunCode(context.Background(), "1", RunCodeOptions{}) + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != http.StatusBadGateway { + t.Fatalf("RunCode error=%v", err) + } + }) + + t.Run("scanner error", func(t *testing.T) { + client := NewClient(Config{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader(strings.Repeat("x", 17*1024*1024))), + Request: req, + }, nil + })})) + sb := &Sandbox{client: client, SandboxID: "sb-run", Domain: "cube.test"} + if _, err := sb.RunCode(context.Background(), "1", RunCodeOptions{}); err == nil { + t.Fatal("RunCode with oversized stream line returned nil error") + } + }) + + t.Run("timeout option", func(t *testing.T) { + client := NewClient(Config{}, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + select { + case <-req.Context().Done(): + return nil, req.Context().Err() + case <-time.After(50 * time.Millisecond): + return &http.Response{ + StatusCode: http.StatusOK, + Body: io.NopCloser(strings.NewReader("")), + Request: req, + }, nil + } + })})) + sb := &Sandbox{client: client, SandboxID: "sb-run", Domain: "cube.test"} + if _, err := sb.RunCode(context.Background(), "1", RunCodeOptions{Timeout: time.Millisecond}); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("RunCode timeout error=%v", err) + } + }) +} + +func TestCommandFallbacksAndErrors(t *testing.T) { + boom := errors.New("boom") + _, err := (&Commands{starter: &fakeProcessStarter{err: boom}}).Run(context.Background(), "echo x", CommandOptions{}) + if !errors.Is(err, boom) { + t.Fatalf("command error=%v", err) + } + + result, err := (&Commands{starter: &fakeProcessStarter{ + result: &processStartResult{ + Stdout: "out\n", + Stderr: "err\n", + ExitCode: 1, + }, + }}).Run(context.Background(), "bad", CommandOptions{}) + if err != nil { + t.Fatalf("command returned error: %v", err) + } + if result.ExitCode != 1 || result.Stdout != "out\n" || result.Stderr != "err\n" { + t.Fatalf("result=%#v", result) + } + + result, err = (&Commands{starter: &fakeProcessStarter{ + result: &processStartResult{ + ExitCode: -2, + }, + }}).Run(context.Background(), "exit -2", CommandOptions{}) + if err != nil { + t.Fatalf("negative command returned error: %v", err) + } + if result.ExitCode != -2 || result.Stdout != "" { + t.Fatalf("negative result=%#v", result) + } + + if _, err = (&Commands{}).Run(context.Background(), "true", CommandOptions{}); err == nil || !strings.Contains(err.Error(), "not attached") { + t.Fatalf("unattached commands error=%v", err) + } +} + +func TestFilesReadErrorAndMainTextFallback(t *testing.T) { + boom := errors.New("boom") + if _, err := (&Files{reader: &fakeFileReader{err: boom}}).Read(context.Background(), "/tmp/x"); !errors.Is(err, boom) { + t.Fatalf("Files.Read error=%v", err) + } + + content, err := (&Files{reader: &fakeFileReader{content: "main"}}).Read(context.Background(), "/tmp/x") + if err != nil || content != "main" { + t.Fatalf("content=%q", content) + } + + if _, err = (&Files{}).Read(context.Background(), "/tmp/x"); err == nil || !strings.Contains(err.Error(), "not attached") { + t.Fatalf("unattached files error=%v", err) + } + + if got := (*Execution)(nil).mainText(); got != "" { + t.Fatalf("nil execution mainText=%q", got) + } + if got := (&Execution{Text: "explicit"}).mainText(); got != "explicit" { + t.Fatalf("explicit mainText=%q", got) + } + if got := (&Execution{}).mainText(); got != "" { + t.Fatalf("empty mainText=%q", got) + } +} + +func TestConfigParsingEdges(t *testing.T) { + t.Setenv("CUBE_PROXY_PORT_HTTP", "abc") + if got := parseIntEnv("CUBE_PROXY_PORT_HTTP", 99); got != 99 { + t.Fatalf("invalid int=%d", got) + } + t.Setenv("CUBE_PROXY_PORT_HTTP", "-1") + if got := parseIntEnv("CUBE_PROXY_PORT_HTTP", 99); got != 99 { + t.Fatalf("negative int=%d", got) + } + t.Setenv("CUBE_PROXY_PORT_HTTP", "123") + if got := parseIntEnv("CUBE_PROXY_PORT_HTTP", 99); got != 123 { + t.Fatalf("parsed int=%d", got) + } + + if got := normalizeProxyScheme("", 443); got != "https" { + t.Fatalf("default 443 proxy scheme=%q", got) + } + if got := normalizeProxyScheme("HTTPS", 80); got != "https" { + t.Fatalf("explicit proxy scheme=%q", got) + } + if got := normalizeProxyScheme("ftp", 80); got != "http" { + t.Fatalf("invalid proxy scheme fallback=%q", got) + } + + t.Setenv("CUBE_TIMEOUT", "-2s") + if got := parseDurationEnv("CUBE_TIMEOUT", 7*time.Second); got != 7*time.Second { + t.Fatalf("negative duration=%s", got) + } + t.Setenv("CUBE_TIMEOUT", "bad") + if got := parseDurationEnv("CUBE_TIMEOUT", 7*time.Second); got != 7*time.Second { + t.Fatalf("bad duration=%s", got) + } + t.Setenv("CUBE_TIMEOUT", "1.5") + if got := parseDurationEnv("CUBE_TIMEOUT", 7*time.Second); got != 1500*time.Millisecond { + t.Fatalf("float seconds duration=%s", got) + } + + if got := durationSeconds(0); got != 0 { + t.Fatalf("durationSeconds(0)=%d", got) + } + if got := durationSeconds(1500 * time.Millisecond); got != 2 { + t.Fatalf("durationSeconds(1.5s)=%d", got) + } +} + +func TestAPIErrorAndMessageEdges(t *testing.T) { + if got := (*APIError)(nil).Error(); got != "" { + t.Fatalf("nil APIError Error=%q", got) + } + if got := (&APIError{Message: "plain"}).Error(); got != "plain" { + t.Fatalf("plain APIError Error=%q", got) + } + if got := (&APIError{StatusCode: 418, Message: "teapot"}).Error(); got != "teapot (HTTP 418)" { + t.Fatalf("status APIError Error=%q", got) + } + if (&APIError{Kind: apiErrorKindAPI}).Is(errors.New("other")) { + t.Fatal("APIError Is matched unrelated error") + } + if (*APIError)(nil).Is(ErrAuthentication) { + t.Fatal("nil APIError Is returned true") + } + + err := apiErrorFromStatus(http.StatusForbidden, "") + if !errors.Is(err, ErrAuthentication) || err.Message != "HTTP 403" { + t.Fatalf("forbidden error=%#v", err) + } + if !errors.Is(apiErrorFromStatus(http.StatusInternalServerError, "sandbox not found downstream"), ErrSandboxNotFound) { + t.Fatal("sandbox not found message was not classified") + } + if errors.Is(apiErrorFromStatus(http.StatusInternalServerError, "unrelated not found"), ErrSandboxNotFound) { + t.Fatal("unrelated not found message classified as sandbox not found") + } + + if got := readErrorMessage(nil); got != "" { + t.Fatalf("nil response message=%q", got) + } + if got := readErrorMessage(&http.Response{}); got != "" { + t.Fatalf("nil body message=%q", got) + } + if got := readErrorMessage(&http.Response{Body: io.NopCloser(strings.NewReader(" "))}); got != "" { + t.Fatalf("blank body message=%q", got) + } + if got := readErrorMessage(&http.Response{Body: io.NopCloser(strings.NewReader(`{"detail":"detail msg"}`))}); got != "detail msg" { + t.Fatalf("detail message=%q", got) + } + if got := readErrorMessage(&http.Response{Body: io.NopCloser(strings.NewReader(`{"message":7}`))}); got != `{"message":7}` { + t.Fatalf("numeric message body=%q", got) + } + if got := readErrorMessage(&http.Response{Body: errReaderCloser{}}); got != "" { + t.Fatalf("read error message=%q", got) + } +} + +func TestParseLineMalformedTypedEventsAndTracebackEdges(t *testing.T) { + execution := &Execution{} + parseLine(execution, nil, RunCodeOptions{}) + parseLine(execution, []byte(`{"type":7}`), RunCodeOptions{}) + parseLine(execution, []byte(`{"type":"result","text":{}}`), RunCodeOptions{}) + parseLine(execution, []byte(`{"type":"stdout","text":{}}`), RunCodeOptions{}) + parseLine(execution, []byte(`{"type":"stderr","text":{}}`), RunCodeOptions{}) + parseLine(execution, []byte(`{"type":"error","traceback":{}}`), RunCodeOptions{}) + parseLine(execution, []byte(`{"type":"error","name":{}}`), RunCodeOptions{}) + parseLine(execution, []byte(`{"type":"number_of_executions","execution_count":"bad"}`), RunCodeOptions{}) + + if len(execution.Results) != 0 || len(execution.Logs.Stdout) != 0 || len(execution.Logs.Stderr) != 0 || execution.ExecutionCount != nil { + t.Fatalf("malformed events changed execution: %#v", execution) + } + if execution.Error == nil || len(execution.Error.Traceback) != 0 { + t.Fatalf("malformed error event mismatch: %#v", execution.Error) + } + + if got := parseTraceback(nil); got != nil { + t.Fatalf("nil traceback=%#v", got) + } + if got := parseTraceback([]byte(`null`)); got != nil { + t.Fatalf("null traceback=%#v", got) + } + if got := parseTraceback([]byte(`""`)); got != nil { + t.Fatalf("empty string traceback=%#v", got) + } + if got := parseTraceback([]byte(`{}`)); got != nil { + t.Fatalf("object traceback=%#v", got) + } +} + +type roundTripFunc func(*http.Request) (*http.Response, error) + +func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return f(req) +} + +type errReaderCloser struct{} + +func (errReaderCloser) Read([]byte) (int, error) { + return 0, errors.New("read failed") +} + +func (errReaderCloser) Close() error { + return nil +} + +type cancelOnCloseBody struct { + *strings.Reader + cancel context.CancelFunc +} + +func (b cancelOnCloseBody) Close() error { + b.cancel() + return nil +} diff --git a/sdk/go/envd.go b/sdk/go/envd.go new file mode 100644 index 00000000..4935ec55 --- /dev/null +++ b/sdk/go/envd.go @@ -0,0 +1,316 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "bytes" + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" +) + +const ( + connectProtocolVersion = "1" + connectContentType = "application/connect+json" + connectEndStreamFlag = byte(0x02) + connectCompressedFlag = byte(0x01) + maxConnectEnvelopeSize = 64 * 1024 * 1024 +) + +type processStartRequest struct { + Process processConfig `json:"process"` + Stdin *bool `json:"stdin,omitempty"` +} + +type processConfig struct { + Cmd string `json:"cmd"` + Args []string `json:"args"` + Envs map[string]string `json:"envs"` + Cwd string `json:"cwd,omitempty"` +} + +type processStartResult struct { + PID int + Stdout string + Stderr string + ExitCode int +} + +type processStartResponse struct { + Event *processEvent `json:"event"` +} + +type processEvent struct { + Start *processStartEvent `json:"start,omitempty"` + Data *processDataEvent `json:"data,omitempty"` + End *processEndEvent `json:"end,omitempty"` + Keepalive *struct{} `json:"keepalive,omitempty"` +} + +type processStartEvent struct { + PID int `json:"pid"` +} + +type processDataEvent struct { + Stdout string `json:"stdout,omitempty"` + Stderr string `json:"stderr,omitempty"` + PTY string `json:"pty,omitempty"` +} + +type processEndEvent struct { + ExitCode *int `json:"exitCode,omitempty"` + ExitCodeSnake *int `json:"exit_code,omitempty"` + Exited bool `json:"exited,omitempty"` + Status string `json:"status,omitempty"` + Error string `json:"error,omitempty"` +} + +type connectEndStream struct { + Error *connectError `json:"error,omitempty"` +} + +type connectError struct { + Code string `json:"code,omitempty"` + Message string `json:"message,omitempty"` +} + +func (s *Sandbox) startProcess(ctx context.Context, payload processStartRequest, opts CommandOptions) (*processStartResult, error) { + if err := s.ensureClient(); err != nil { + return nil, err + } + + if opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + } + + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := s.newEnvdRequest(ctx, http.MethodPost, "/process.Process/Start", nil, bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", connectContentType) + req.Header.Set("Connect-Protocol-Version", connectProtocolVersion) + setConnectTimeout(req, opts.Timeout) + + resp, err := s.client.dataHTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return nil, apiErrorFromResponse(resp) + } + + result, err := parseProcessStartStream(resp.Body) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *Sandbox) readFile(ctx context.Context, path string) (string, error) { + if err := s.ensureClient(); err != nil { + return "", err + } + + query := url.Values{"path": []string{path}} + req, err := s.newEnvdRequest(ctx, http.MethodGet, "/files", query, nil) + if err != nil { + return "", err + } + + resp, err := s.client.dataHTTP.Do(req) + if err != nil { + return "", err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + message := readErrorMessage(resp) + if message == "" { + message = fmt.Sprintf("HTTP %d", resp.StatusCode) + } + return "", fmt.Errorf("failed to read %s: %s", path, message) + } + + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "", err + } + return string(raw), nil +} + +func (s *Sandbox) newEnvdRequest(ctx context.Context, method, path string, query url.Values, body io.Reader) (*http.Request, error) { + target := url.URL{ + Scheme: s.client.config.ProxyScheme, + Host: s.GetHost(JupyterPort), + Path: path, + RawQuery: query.Encode(), + } + + req, err := http.NewRequestWithContext(ctx, method, target.String(), body) + if err != nil { + return nil, err + } + if s.EnvdAccessToken != "" { + req.Header.Set("X-Access-Token", s.EnvdAccessToken) + } + return req, nil +} + +func setConnectTimeout(req *http.Request, timeout time.Duration) { + if timeout <= 0 { + return + } + req.Header.Set("Connect-Timeout-Ms", strconv.FormatInt(timeout.Milliseconds(), 10)) +} + +func parseProcessStartStream(r io.Reader) (*processStartResult, error) { + var result processStartResult + var stdout strings.Builder + var stderr strings.Builder + sawEnd := false + + for { + flags, payload, err := readConnectEnvelope(r) + if err != nil { + if err == io.EOF { + break + } + return nil, err + } + if flags&connectCompressedFlag != 0 { + return nil, fmt.Errorf("unsupported compressed Connect stream message") + } + if flags&connectEndStreamFlag != 0 { + if err := parseConnectEndStream(payload); err != nil { + return nil, err + } + continue + } + + var response processStartResponse + if err := json.Unmarshal(payload, &response); err != nil { + return nil, fmt.Errorf("decode process event: %w", err) + } + if response.Event == nil { + continue + } + if response.Event.Start != nil { + result.PID = response.Event.Start.PID + } + if response.Event.Data != nil { + if response.Event.Data.Stdout != "" { + text, err := decodeProcessBytes(response.Event.Data.Stdout) + if err != nil { + return nil, fmt.Errorf("decode stdout: %w", err) + } + stdout.WriteString(text) + } + if response.Event.Data.Stderr != "" { + text, err := decodeProcessBytes(response.Event.Data.Stderr) + if err != nil { + return nil, fmt.Errorf("decode stderr: %w", err) + } + stderr.WriteString(text) + } + } + if response.Event.End != nil { + exitCode, ok := response.Event.End.exitCode() + if !ok { + if response.Event.End.Error != "" { + return nil, fmt.Errorf("process failed: %s", response.Event.End.Error) + } + return nil, fmt.Errorf("process EndEvent missing exit code") + } + result.ExitCode = exitCode + sawEnd = true + } + } + + if !sawEnd { + return nil, fmt.Errorf("process stream ended without EndEvent") + } + result.Stdout = stdout.String() + result.Stderr = stderr.String() + return &result, nil +} + +func readConnectEnvelope(r io.Reader) (byte, []byte, error) { + var header [5]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + if err == io.EOF || err == io.ErrUnexpectedEOF { + return 0, nil, err + } + return 0, nil, err + } + + size := binary.BigEndian.Uint32(header[1:]) + if size > maxConnectEnvelopeSize { + return 0, nil, fmt.Errorf("Connect stream message too large: %d bytes", size) + } + payload := make([]byte, size) + if _, err := io.ReadFull(r, payload); err != nil { + return 0, nil, err + } + return header[0], payload, nil +} + +func parseConnectEndStream(raw []byte) error { + if len(raw) == 0 { + return nil + } + + var end connectEndStream + if err := json.Unmarshal(raw, &end); err != nil { + return fmt.Errorf("decode Connect end stream: %w", err) + } + if end.Error == nil { + return nil + } + message := strings.TrimSpace(end.Error.Message) + if message == "" { + message = "Connect stream error" + } + if end.Error.Code != "" { + return fmt.Errorf("%s: %s", end.Error.Code, message) + } + return fmt.Errorf("%s", message) +} + +func decodeProcessBytes(value string) (string, error) { + raw, err := base64.StdEncoding.DecodeString(value) + if err != nil { + return "", err + } + return string(raw), nil +} + +func (e *processEndEvent) exitCode() (int, bool) { + if e == nil { + return 0, false + } + if e.ExitCode != nil { + return *e.ExitCode, true + } + if e.ExitCodeSnake != nil { + return *e.ExitCodeSnake, true + } + return 0, false +} diff --git a/sdk/go/errors.go b/sdk/go/errors.go new file mode 100644 index 00000000..ddd9f234 --- /dev/null +++ b/sdk/go/errors.go @@ -0,0 +1,122 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "strings" +) + +const ( + apiErrorKindAPI = "api" + apiErrorKindAuthentication = "authentication" + apiErrorKindSandboxNotFound = "sandbox_not_found" + apiErrorKindTemplateNotFound = "template_not_found" +) + +var ( + ErrAuthentication = errors.New("cubesandbox: authentication failed") + ErrSandboxNotFound = errors.New("cubesandbox: sandbox not found") + ErrTemplateNotFound = errors.New("cubesandbox: template not found") +) + +// APIError describes an HTTP error returned by the CubeSandbox API. +type APIError struct { + StatusCode int + Message string + Kind string +} + +func (e *APIError) Error() string { + if e == nil { + return "" + } + if e.StatusCode == 0 { + return e.Message + } + return fmt.Sprintf("%s (HTTP %d)", e.Message, e.StatusCode) +} + +func (e *APIError) Is(target error) bool { + if e == nil { + return false + } + switch target { + case ErrAuthentication: + return e.Kind == apiErrorKindAuthentication + case ErrSandboxNotFound: + return e.Kind == apiErrorKindSandboxNotFound + case ErrTemplateNotFound: + return e.Kind == apiErrorKindTemplateNotFound + default: + return false + } +} + +func apiErrorFromResponse(resp *http.Response) error { + message := readErrorMessage(resp) + return apiErrorFromStatus(resp.StatusCode, message) +} + +func apiErrorFromStatus(statusCode int, message string) *APIError { + message = strings.TrimSpace(message) + if message == "" { + message = fmt.Sprintf("HTTP %d", statusCode) + } + + kind := apiErrorKindAPI + lowerMessage := strings.ToLower(message) + switch statusCode { + case http.StatusUnauthorized, http.StatusForbidden: + kind = apiErrorKindAuthentication + case http.StatusNotFound: + if strings.Contains(lowerMessage, "template") { + kind = apiErrorKindTemplateNotFound + } else { + kind = apiErrorKindSandboxNotFound + } + } + if kind == apiErrorKindAPI && strings.Contains(lowerMessage, "not found") { + switch { + case strings.Contains(lowerMessage, "template"): + kind = apiErrorKindTemplateNotFound + case strings.Contains(lowerMessage, "sandbox"): + kind = apiErrorKindSandboxNotFound + } + } + + return &APIError{ + StatusCode: statusCode, + Message: message, + Kind: kind, + } +} + +func readErrorMessage(resp *http.Response) string { + if resp == nil || resp.Body == nil { + return "" + } + raw, err := io.ReadAll(resp.Body) + if err != nil { + return "" + } + raw = []byte(strings.TrimSpace(string(raw))) + if len(raw) == 0 { + return "" + } + + var body map[string]any + if err := json.Unmarshal(raw, &body); err == nil { + for _, key := range []string{"message", "detail"} { + if value, ok := body[key].(string); ok && strings.TrimSpace(value) != "" { + return value + } + } + } + return string(raw) +} diff --git a/sdk/go/files.go b/sdk/go/files.go new file mode 100644 index 00000000..ab4337e5 --- /dev/null +++ b/sdk/go/files.go @@ -0,0 +1,24 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "context" + "fmt" +) + +type Files struct { + reader fileReader +} + +type fileReader interface { + readFile(context.Context, string) (string, error) +} + +func (f *Files) Read(ctx context.Context, path string) (string, error) { + if f == nil || f.reader == nil { + return "", fmt.Errorf("files is not attached to a sandbox") + } + return f.reader.readFile(ctx, path) +} diff --git a/sdk/go/go.mod b/sdk/go/go.mod new file mode 100644 index 00000000..c5ed25db --- /dev/null +++ b/sdk/go/go.mod @@ -0,0 +1,3 @@ +module github.com/tencentcloud/CubeSandbox/sdk/go + +go 1.22 diff --git a/sdk/go/integration_test.go b/sdk/go/integration_test.go new file mode 100644 index 00000000..2c1b3488 --- /dev/null +++ b/sdk/go/integration_test.go @@ -0,0 +1,346 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration + +package cubesandbox + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "os" + "strings" + "testing" + "time" +) + +func TestIntegrationHealthTemplateAndList(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + cfg := integrationConfig(t) + client := NewClient(cfg) + + health, err := client.Health(ctx) + if err != nil { + t.Fatalf("Health returned error: %v", err) + } + if health["status"] != "ok" { + t.Fatalf("health status=%#v, want ok; full response=%#v", health["status"], health) + } + + if cfg.TemplateID == "" { + t.Fatal("integration config did not resolve a template ID") + } + + list, err := client.List(ctx) + if err != nil { + t.Fatalf("List returned error: %v", err) + } + if list == nil { + t.Fatal("List returned nil slice") + } + + listV2, err := client.ListV2(ctx) + if err != nil { + t.Fatalf("ListV2 returned error: %v", err) + } + if listV2 == nil { + t.Fatal("ListV2 returned nil slice") + } +} + +func TestIntegrationSandboxExecutionCommandsFilesAndErrors(t *testing.T) { + cfg := integrationConfig(t) + client := NewClient(cfg) + ctx, cancel := context.WithTimeout(context.Background(), 4*time.Minute) + defer cancel() + + sb := createIntegrationSandbox(t, ctx, client, CreateOptions{ + Timeout: 2 * time.Minute, + EnvVars: map[string]string{ + "CUBE_GO_SDK_CREATE_ENV": "create-env-ok", + }, + Metadata: map[string]string{ + "sdk": "go", + "scenario": "integration-execution", + }, + }) + + info, err := sb.GetInfo(ctx) + if err != nil { + t.Fatalf("GetInfo returned error: %v", err) + } + if info.SandboxID != sb.SandboxID { + t.Fatalf("GetInfo sandboxID=%q, want %q", info.SandboxID, sb.SandboxID) + } + if info.State == "" { + t.Fatalf("GetInfo state is empty: %#v", info) + } + if info.Metadata != nil && info.Metadata["scenario"] != "integration-execution" { + t.Fatalf("metadata scenario=%q", info.Metadata["scenario"]) + } + + assertListContainsSandbox(t, ctx, client, sb.SandboxID) + + var stdoutEvents, stderrEvents, resultEvents int + exec, err := sb.RunCode(ctx, strings.Join([]string{ + "import os, sys", + "print('stdout-one')", + "print(os.environ.get('CUBE_GO_SDK_RUN_ENV', 'missing'))", + "sys.stderr.write('stderr-one\\n')", + "'result-one'", + }, "\n"), RunCodeOptions{ + Envs: map[string]string{ + "CUBE_GO_SDK_RUN_ENV": "run-env-ok", + }, + Timeout: 45 * time.Second, + OnStdout: func(message OutputMessage) { + stdoutEvents++ + }, + OnStderr: func(message OutputMessage) { + stderrEvents++ + if !message.IsStderr { + t.Errorf("stderr callback IsStderr=false for %#v", message) + } + }, + OnResult: func(Result) { + resultEvents++ + }, + }) + if err != nil { + t.Fatalf("RunCode returned error: %v", err) + } + if exec.Error != nil { + t.Fatalf("RunCode execution error: %#v", exec.Error) + } + if !strings.Contains(strings.Join(exec.Logs.Stdout, ""), "stdout-one") { + t.Fatalf("stdout missing expected marker: %#v", exec.Logs.Stdout) + } + if !strings.Contains(strings.Join(exec.Logs.Stdout, ""), "run-env-ok") { + t.Fatalf("stdout missing run env marker: %#v", exec.Logs.Stdout) + } + if !strings.Contains(strings.Join(exec.Logs.Stderr, ""), "stderr-one") { + t.Fatalf("stderr missing expected marker: %#v", exec.Logs.Stderr) + } + if exec.Text != "result-one" { + t.Fatalf("execution text=%q, want result-one; results=%#v", exec.Text, exec.Results) + } + if stdoutEvents == 0 || stderrEvents == 0 || resultEvents == 0 { + t.Fatalf("callbacks not invoked: stdout=%d stderr=%d result=%d", stdoutEvents, stderrEvents, resultEvents) + } + + errExec, err := sb.RunCode(ctx, "raise ValueError('integration-boom')", RunCodeOptions{ + Timeout: 30 * time.Second, + }) + if err != nil { + t.Fatalf("RunCode error scenario returned transport error: %v", err) + } + if errExec.Error == nil || errExec.Error.Name != "ValueError" || !strings.Contains(errExec.Error.Value, "integration-boom") { + t.Fatalf("execution error mismatch: %#v", errExec.Error) + } + + cmd, err := sb.Commands().Run(ctx, "printf 'cmd-out\\n'; >&2 printf 'cmd-err\\n'; exit 7", CommandOptions{ + Timeout: 30 * time.Second, + }) + if err != nil { + t.Fatalf("Commands.Run returned error: %v", err) + } + if cmd.Stdout != "cmd-out\n" || cmd.Stderr != "cmd-err\n" || cmd.ExitCode != 7 { + t.Fatalf("command result mismatch: %#v", cmd) + } + + path := "/tmp/cubesandbox-go-sdk-integration.txt" + writeFile, err := sb.Commands().Run(ctx, fmt.Sprintf("printf %%s file-content-ok > %s", path), CommandOptions{ + Timeout: 30 * time.Second, + }) + if err != nil { + t.Fatalf("write fixture file returned error: %v", err) + } + if writeFile.ExitCode != 0 { + t.Fatalf("write fixture command failed: %#v", writeFile) + } + content, err := sb.Files().Read(ctx, path) + if err != nil { + t.Fatalf("Files.Read returned error: %v", err) + } + if content != "file-content-ok" { + t.Fatalf("file content=%q", content) + } + if _, err := sb.Files().Read(ctx, "/tmp/cubesandbox-go-sdk-does-not-exist"); err == nil { + t.Fatal("Files.Read missing file returned nil error") + } + + _, err = client.Create(ctx, CreateOptions{TemplateID: "tpl-go-sdk-integration-missing-template"}) + if !errors.Is(err, ErrTemplateNotFound) { + t.Fatalf("missing template error=%v, want ErrTemplateNotFound", err) + } +} + +func TestIntegrationPauseConnectAndResumeExecution(t *testing.T) { + cfg := integrationConfig(t) + client := NewClient(cfg) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) + defer cancel() + + sb := createIntegrationSandbox(t, ctx, client, CreateOptions{ + Timeout: 3 * time.Minute, + Metadata: map[string]string{ + "sdk": "go", + "scenario": "integration-pause-connect", + }, + }) + + wait := true + if err := sb.Pause(ctx, PauseOptions{ + Wait: &wait, + Timeout: 90 * time.Second, + Interval: 2 * time.Second, + }); err != nil { + t.Fatalf("Pause returned error: %v", err) + } + + info, err := sb.GetInfo(ctx) + if err != nil { + t.Fatalf("GetInfo after pause returned error: %v", err) + } + if info.State != "paused" { + t.Fatalf("state after pause=%q, want paused", info.State) + } + + resumed, err := client.Connect(ctx, sb.SandboxID) + if err != nil { + t.Fatalf("Connect after pause returned error: %v", err) + } + sb = resumed + + exec, err := sb.RunCode(ctx, "print('resumed-ok')\n'resumed-result'", RunCodeOptions{ + Timeout: 45 * time.Second, + }) + if err != nil { + t.Fatalf("RunCode after connect returned error: %v", err) + } + if exec.Error != nil { + t.Fatalf("RunCode after connect execution error: %#v", exec.Error) + } + if !strings.Contains(strings.Join(exec.Logs.Stdout, ""), "resumed-ok") || exec.Text != "resumed-result" { + t.Fatalf("resume execution mismatch: text=%q stdout=%#v", exec.Text, exec.Logs.Stdout) + } +} + +func integrationConfig(t *testing.T) Config { + t.Helper() + if testing.Short() { + t.Skip("skipping CubeSandbox integration test in short mode") + } + + cfg := NewConfigFromEnv() + if os.Getenv("CUBE_API_URL") == "" && os.Getenv("E2B_API_URL") == "" { + t.Skip("set CUBE_API_URL to run CubeSandbox integration tests") + } + if os.Getenv("CUBE_PROXY_PORT_HTTP") == "" { + cfg.ProxyPortHTTP = 80 + } + if os.Getenv("CUBE_SANDBOX_DOMAIN") == "" { + cfg.SandboxDomain = "cube.app" + } + cfg.Timeout = 3 * time.Minute + cfg.RequestTimeout = 10 * time.Second + cfg = normalizeConfig(cfg) + + if cfg.TemplateID == "" { + cfg.TemplateID = discoverReadyTemplate(t, cfg) + } + t.Logf("CubeSandbox integration target api=%s template=%s proxy=%s:%d domain=%s", + cfg.APIURL, cfg.TemplateID, cfg.ProxyNodeIP, cfg.ProxyPortHTTP, cfg.SandboxDomain) + return cfg +} + +func discoverReadyTemplate(t *testing.T, cfg Config) string { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, cfg.APIURL+"/templates", nil) + if err != nil { + t.Fatalf("build templates request: %v", err) + } + if cfg.APIKey != "" { + req.Header.Set("Authorization", "Bearer "+cfg.APIKey) + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("list templates from %s: %v", cfg.APIURL, err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + t.Fatalf("list templates HTTP %d", resp.StatusCode) + } + + var templates []struct { + TemplateID string `json:"templateID"` + Status string `json:"status"` + } + if err := json.NewDecoder(resp.Body).Decode(&templates); err != nil { + t.Fatalf("decode templates: %v", err) + } + for _, template := range templates { + if template.TemplateID != "" && strings.EqualFold(template.Status, "READY") { + return template.TemplateID + } + } + if len(templates) > 0 && templates[0].TemplateID != "" { + return templates[0].TemplateID + } + t.Fatalf("no templates found at %s; set CUBE_TEMPLATE_ID", cfg.APIURL) + return "" +} + +func createIntegrationSandbox(t *testing.T, ctx context.Context, client *Client, opts CreateOptions) *Sandbox { + t.Helper() + sb, err := client.Create(ctx, opts) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + + t.Cleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), 45*time.Second) + defer cancel() + if err := sb.Kill(cleanupCtx); err != nil && !errors.Is(err, ErrSandboxNotFound) { + t.Logf("cleanup kill sandbox %s failed: %v", sb.SandboxID, err) + } + }) + + if sb.SandboxID == "" { + t.Fatal("created sandbox has empty sandboxID") + } + if sb.GetHost(JupyterPort) == "" { + t.Fatal("created sandbox returned empty data-plane host") + } + return sb +} + +func assertListContainsSandbox(t *testing.T, ctx context.Context, client *Client, sandboxID string) { + t.Helper() + deadline := time.Now().Add(20 * time.Second) + for { + list, err := client.ListV2(ctx) + if err != nil { + t.Fatalf("ListV2 returned error: %v", err) + } + for _, item := range list { + if item.SandboxID == sandboxID { + return + } + } + if time.Now().After(deadline) { + t.Fatalf("sandbox %s not found in ListV2", sandboxID) + } + time.Sleep(time.Second) + } +} diff --git a/sdk/go/models.go b/sdk/go/models.go new file mode 100644 index 00000000..19b4fd81 --- /dev/null +++ b/sdk/go/models.go @@ -0,0 +1,142 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import "time" + +// Sandbox is a connected CubeSandbox instance returned by create/connect. +type Sandbox struct { + client *Client `json:"-"` + + TemplateID string `json:"templateID"` + SandboxID string `json:"sandboxID"` + Alias string `json:"alias,omitempty"` + ClientID string `json:"clientID"` + EnvdVersion string `json:"envdVersion"` + EnvdAccessToken string `json:"envdAccessToken,omitempty"` + TrafficAccessToken string `json:"trafficAccessToken,omitempty"` + Domain string `json:"domain,omitempty"` +} + +// SandboxInfo is returned by list and get-info endpoints. +type SandboxInfo struct { + TemplateID string `json:"templateID"` + Alias string `json:"alias,omitempty"` + SandboxID string `json:"sandboxID"` + ClientID string `json:"clientID"` + StartedAt time.Time `json:"startedAt"` + EndAt time.Time `json:"endAt"` + EnvdVersion string `json:"envdVersion"` + Domain string `json:"domain,omitempty"` + CPUCount int `json:"cpuCount"` + MemoryMB int `json:"memoryMB"` + DiskSizeMB *int `json:"diskSizeMB,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + State string `json:"state"` + VolumeMounts []VolumeMount `json:"volumeMounts,omitempty"` +} + +type VolumeMount struct { + Name string `json:"name"` + Path string `json:"path"` +} + +type NetworkOptions struct { + AllowOut []string + DenyOut []string +} + +type CreateOptions struct { + TemplateID string + Timeout time.Duration + EnvVars map[string]string + Metadata map[string]string + AllowInternetAccess *bool + Network NetworkOptions + Extra map[string]any +} + +type PauseOptions struct { + Wait *bool + Timeout time.Duration + Interval time.Duration +} + +type RunCodeOptions struct { + Language string + Envs map[string]string + Timeout time.Duration + + OnStdout func(OutputMessage) + OnStderr func(OutputMessage) + OnResult func(Result) + OnError func(ExecutionError) +} + +type CommandOptions struct { + Timeout time.Duration + Envs map[string]string + Cwd string +} + +type CommandResult struct { + Stdout string + Stderr string + ExitCode int +} + +type Logs struct { + Stdout []string + Stderr []string +} + +type ExecutionError struct { + Name string `json:"name"` + Value string `json:"value"` + Traceback []string `json:"traceback"` +} + +type Result struct { + Text string `json:"text,omitempty"` + HTML string `json:"html,omitempty"` + Markdown string `json:"markdown,omitempty"` + SVG string `json:"svg,omitempty"` + PNG string `json:"png,omitempty"` + JPEG string `json:"jpeg,omitempty"` + PDF string `json:"pdf,omitempty"` + Latex string `json:"latex,omitempty"` + JSONData map[string]any `json:"json_data,omitempty"` + JavaScript string `json:"javascript,omitempty"` + IsMainResult bool `json:"is_main_result,omitempty"` + Extra map[string]any `json:"extra,omitempty"` +} + +type Execution struct { + Results []Result + Logs Logs + Error *ExecutionError + ExecutionCount *int + Text string +} + +type OutputMessage struct { + Text string + Timestamp string + IsStderr bool +} + +func (e *Execution) mainText() string { + if e == nil { + return "" + } + if e.Text != "" { + return e.Text + } + for _, result := range e.Results { + if result.IsMainResult { + return result.Text + } + } + return "" +} diff --git a/sdk/go/sandbox.go b/sdk/go/sandbox.go new file mode 100644 index 00000000..64811c25 --- /dev/null +++ b/sdk/go/sandbox.go @@ -0,0 +1,195 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "time" +) + +const JupyterPort = 49999 + +func (s *Sandbox) GetHost(port int) string { + domain := s.Domain + if domain == "" && s.client != nil { + domain = s.client.config.SandboxDomain + } + return fmt.Sprintf("%d-%s.%s", port, s.SandboxID, domain) +} + +func (s *Sandbox) GetInfo(ctx context.Context) (*SandboxInfo, error) { + if err := s.ensureClient(); err != nil { + return nil, err + } + + var info SandboxInfo + path := "/sandboxes/" + url.PathEscape(s.SandboxID) + if err := s.client.doJSON(ctx, http.MethodGet, path, nil, &info, http.StatusOK); err != nil { + return nil, err + } + return &info, nil +} + +func (s *Sandbox) Pause(ctx context.Context, opts PauseOptions) error { + if err := s.ensureClient(); err != nil { + return err + } + + path := "/sandboxes/" + url.PathEscape(s.SandboxID) + "/pause" + if err := s.client.doJSON(ctx, http.MethodPost, path, nil, nil, http.StatusOK, http.StatusNoContent); err != nil { + return err + } + if !pauseShouldWait(opts) { + return nil + } + + timeout := opts.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + interval := opts.Interval + if interval < 0 { + interval = 0 + } + if interval == 0 { + interval = time.Second + } + + deadline := time.NewTimer(timeout) + defer deadline.Stop() + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + info, err := s.GetInfo(ctx) + if err != nil { + return err + } + if info.State == "paused" { + return nil + } + + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline.C: + return fmt.Errorf("sandbox %q did not reach 'paused' state within %s", s.SandboxID, timeout) + case <-ticker.C: + } + } +} + +// Resume resumes a paused sandbox. +// +// Deprecated: use Client.Connect instead, which auto-resumes paused sandboxes +// and returns a fresh Sandbox instance. +func (s *Sandbox) Resume(ctx context.Context, timeout time.Duration) error { + if err := s.ensureClient(); err != nil { + return err + } + if timeout <= 0 { + timeout = s.client.config.Timeout + } + + path := "/sandboxes/" + url.PathEscape(s.SandboxID) + "/resume" + payload := map[string]any{"timeout": durationSeconds(timeout)} + return s.client.doJSON(ctx, http.MethodPost, path, payload, nil, http.StatusOK, http.StatusCreated, http.StatusNoContent) +} + +func (s *Sandbox) Kill(ctx context.Context) error { + if err := s.ensureClient(); err != nil { + return err + } + + path := "/sandboxes/" + url.PathEscape(s.SandboxID) + return s.client.doJSON(ctx, http.MethodDelete, path, nil, nil, http.StatusOK, http.StatusNoContent) +} + +func (s *Sandbox) Close() error { + if s.client == nil { + return nil + } + if s.client.dataHTTP != nil { + s.client.dataHTTP.CloseIdleConnections() + } + if s.client.controlHTTP != nil { + s.client.controlHTTP.CloseIdleConnections() + } + return nil +} + +func (s *Sandbox) RunCode(ctx context.Context, code string, opts RunCodeOptions) (*Execution, error) { + if err := s.ensureClient(); err != nil { + return nil, err + } + + if opts.Timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, opts.Timeout) + defer cancel() + } + + payload := map[string]any{ + "code": code, + "language": nil, + "env_vars": nil, + } + if opts.Language != "" { + payload["language"] = opts.Language + } + if opts.Envs != nil { + payload["env_vars"] = opts.Envs + } + + raw, err := json.Marshal(payload) + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.client.config.ProxyScheme+"://"+s.GetHost(JupyterPort)+"/execute", bytes.NewReader(raw)) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "application/json") + + resp, err := s.client.dataHTTP.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode >= http.StatusBadRequest { + return nil, apiErrorFromStatus(resp.StatusCode, fmt.Sprintf("execute failed: HTTP %d", resp.StatusCode)) + } + + execution := &Execution{} + if err := parseStream(resp.Body, execution, opts); err != nil { + return nil, err + } + return execution, nil +} + +func (s *Sandbox) Commands() *Commands { + return &Commands{starter: s} +} + +func (s *Sandbox) Files() *Files { + return &Files{reader: s} +} + +func (s *Sandbox) ensureClient() error { + if s == nil || s.client == nil { + return fmt.Errorf("sandbox is not attached to a client") + } + return nil +} + +func pauseShouldWait(opts PauseOptions) bool { + return opts.Wait == nil || *opts.Wait +} diff --git a/sdk/go/sdk_test.go b/sdk/go/sdk_test.go new file mode 100644 index 00000000..1401cc3b --- /dev/null +++ b/sdk/go/sdk_test.go @@ -0,0 +1,853 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "context" + "encoding/base64" + "encoding/binary" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "testing" + "time" +) + +const testSandboxID = "sb-test-001" + +func TestNewConfigFromEnv(t *testing.T) { + clearEnv(t) + + cfg := NewConfigFromEnv() + if cfg.APIURL != defaultAPIURL { + t.Fatalf("APIURL=%q, want %q", cfg.APIURL, defaultAPIURL) + } + if cfg.ProxyPortHTTP != 80 { + t.Fatalf("ProxyPortHTTP=%d, want 80", cfg.ProxyPortHTTP) + } + if cfg.ProxyScheme != "http" { + t.Fatalf("ProxyScheme=%q, want http", cfg.ProxyScheme) + } + if cfg.SandboxDomain != "cube.app" { + t.Fatalf("SandboxDomain=%q, want cube.app", cfg.SandboxDomain) + } + if cfg.TemplateID != "" || cfg.ProxyNodeIP != "" { + t.Fatalf("unexpected default template/proxy: %#v", cfg) + } + + t.Setenv("E2B_API_URL", "http://e2b.local:3000/") + t.Setenv("E2B_API_KEY", "e2b-key") + t.Setenv("CUBE_API_URL", "http://cube.local:3000/") + t.Setenv("CUBE_API_KEY", "cube-key") + t.Setenv("CUBE_TEMPLATE_ID", "tpl-env") + t.Setenv("CUBE_PROXY_NODE_IP", "10.0.0.8") + t.Setenv("CUBE_PROXY_PORT_HTTP", "9090") + t.Setenv("CUBE_PROXY_SCHEME", "https") + t.Setenv("CUBE_SANDBOX_DOMAIN", "sandbox.internal") + t.Setenv("CUBE_TIMEOUT", "600") + t.Setenv("CUBE_REQUEST_TIMEOUT", "2s") + + cfg = NewConfigFromEnv() + if cfg.APIURL != "http://cube.local:3000" { + t.Fatalf("APIURL=%q", cfg.APIURL) + } + if cfg.APIKey != "cube-key" || cfg.TemplateID != "tpl-env" { + t.Fatalf("APIKey/TemplateID mismatch: %#v", cfg) + } + if cfg.ProxyNodeIP != "10.0.0.8" || cfg.ProxyPortHTTP != 9090 { + t.Fatalf("proxy mismatch: %#v", cfg) + } + if cfg.ProxyScheme != "https" { + t.Fatalf("ProxyScheme=%q", cfg.ProxyScheme) + } + if cfg.SandboxDomain != "sandbox.internal" { + t.Fatalf("SandboxDomain=%q", cfg.SandboxDomain) + } + if cfg.Timeout != 600*time.Second || cfg.RequestTimeout != 2*time.Second { + t.Fatalf("timeouts mismatch: %#v", cfg) + } +} + +func TestCreateSendsPythonCompatiblePayload(t *testing.T) { + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/sandboxes" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + if auth := r.Header.Get("Authorization"); auth != "Bearer test-key" { + t.Fatalf("Authorization=%q", auth) + } + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, sandboxJSON(testSandboxID, "tpl-env")) + })) + defer server.Close() + + disallowInternet := false + client := NewClient(Config{ + APIURL: server.URL + "/", + APIKey: "test-key", + TemplateID: "tpl-env", + Timeout: 300 * time.Second, + RequestTimeout: time.Second, + SandboxDomain: "cube.app", + }) + + sb, err := client.Create(context.Background(), CreateOptions{ + Timeout: 600 * time.Second, + EnvVars: map[string]string{"FOO": "bar"}, + Metadata: map[string]string{"network-policy": "custom"}, + AllowInternetAccess: &disallowInternet, + Network: NetworkOptions{ + AllowOut: []string{"8.8.8.8/32"}, + DenyOut: []string{"0.0.0.0/0"}, + }, + Extra: map[string]any{"mcp": map[string]any{"enabled": true}}, + }) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + if sb.SandboxID != testSandboxID || sb.Domain != "cube.app" { + t.Fatalf("sandbox mismatch: %#v", sb) + } + + assertString(t, got, "templateID", "tpl-env") + assertNumber(t, got, "timeout", 600) + assertMapString(t, got["envVars"], "FOO", "bar") + assertMapString(t, got["metadata"], "network-policy", "custom") + if got["allowInternetAccess"] != false { + t.Fatalf("allowInternetAccess=%#v, want false", got["allowInternetAccess"]) + } + network, ok := got["network"].(map[string]any) + if !ok { + t.Fatalf("network=%#v", got["network"]) + } + assertStringSlice(t, network["allowOut"], []string{"8.8.8.8/32"}) + assertStringSlice(t, network["denyOut"], []string{"0.0.0.0/0"}) + if _, ok := got["mcp"].(map[string]any); !ok { + t.Fatalf("extra field not preserved: %#v", got["mcp"]) + } +} + +func TestCreateOmitsOptionalFieldsAndRequiresTemplate(t *testing.T) { + var got map[string]any + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + t.Fatalf("decode body: %v", err) + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, sandboxJSON(testSandboxID, "tpl-explicit")) + })) + defer server.Close() + + allowInternet := true + client := NewClient(Config{APIURL: server.URL, Timeout: 300 * time.Second}) + if _, err := client.Create(context.Background(), CreateOptions{}); err == nil { + t.Fatal("Create without template returned nil error") + } + + _, err := client.Create(context.Background(), CreateOptions{ + TemplateID: "tpl-explicit", + AllowInternetAccess: &allowInternet, + }) + if err != nil { + t.Fatalf("Create returned error: %v", err) + } + if _, ok := got["allowInternetAccess"]; ok { + t.Fatalf("allowInternetAccess should be omitted when true: %#v", got) + } + if _, ok := got["network"]; ok { + t.Fatalf("network should be omitted when empty: %#v", got) + } +} + +func TestLifecycleEndpoints(t *testing.T) { + var calls []string + var connectTimeout, resumeTimeout int + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls = append(calls, r.Method+" "+r.URL.Path) + w.Header().Set("Content-Type", "application/json") + + switch { + case r.Method == http.MethodPost && r.URL.Path == "/sandboxes/"+testSandboxID+"/connect": + var body map[string]int + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode connect: %v", err) + } + connectTimeout = body["timeout"] + fmt.Fprint(w, sandboxJSON(testSandboxID, "tpl-test")) + case r.Method == http.MethodGet && r.URL.Path == "/sandboxes": + fmt.Fprint(w, "["+sandboxInfoJSON(testSandboxID, "running")+"]") + case r.Method == http.MethodGet && r.URL.Path == "/v2/sandboxes": + fmt.Fprint(w, "["+sandboxInfoJSON(testSandboxID, "paused")+"]") + case r.Method == http.MethodGet && r.URL.Path == "/health": + fmt.Fprint(w, `{"status":"ok","sandboxes":1}`) + case r.Method == http.MethodGet && r.URL.Path == "/sandboxes/"+testSandboxID: + fmt.Fprint(w, sandboxInfoJSON(testSandboxID, "paused")) + case r.Method == http.MethodPost && r.URL.Path == "/sandboxes/"+testSandboxID+"/pause": + w.WriteHeader(http.StatusNoContent) + case r.Method == http.MethodPost && r.URL.Path == "/sandboxes/"+testSandboxID+"/resume": + var body map[string]int + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("decode resume: %v", err) + } + resumeTimeout = body["timeout"] + w.WriteHeader(http.StatusCreated) + fmt.Fprint(w, sandboxJSON(testSandboxID, "tpl-test")) + case r.Method == http.MethodDelete && r.URL.Path == "/sandboxes/"+testSandboxID: + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unexpected request %s %s", r.Method, r.URL.Path) + } + })) + defer server.Close() + + client := NewClient(Config{APIURL: server.URL, TemplateID: "tpl-test", Timeout: 600 * time.Second}) + ctx := context.Background() + + sb, err := client.Connect(ctx, testSandboxID) + if err != nil { + t.Fatalf("Connect: %v", err) + } + if connectTimeout != 600 { + t.Fatalf("connect timeout=%d", connectTimeout) + } + + list, err := client.List(ctx) + if err != nil || len(list) != 1 || list[0].State != "running" { + t.Fatalf("List=%#v err=%v", list, err) + } + list, err = client.ListV2(ctx) + if err != nil || len(list) != 1 || list[0].State != "paused" { + t.Fatalf("ListV2=%#v err=%v", list, err) + } + health, err := client.Health(ctx) + if err != nil || health["status"] != "ok" { + t.Fatalf("Health=%#v err=%v", health, err) + } + info, err := sb.GetInfo(ctx) + if err != nil || info.State != "paused" { + t.Fatalf("GetInfo=%#v err=%v", info, err) + } + wait := false + if err := sb.Pause(ctx, PauseOptions{Wait: &wait}); err != nil { + t.Fatalf("Pause: %v", err) + } + if err := sb.Resume(ctx, 120*time.Second); err != nil { + t.Fatalf("Resume: %v", err) + } + if resumeTimeout != 120 { + t.Fatalf("resume timeout=%d", resumeTimeout) + } + if err := sb.Kill(ctx); err != nil { + t.Fatalf("Kill: %v", err) + } + + want := []string{ + "POST /sandboxes/" + testSandboxID + "/connect", + "GET /sandboxes", + "GET /v2/sandboxes", + "GET /health", + "GET /sandboxes/" + testSandboxID, + "POST /sandboxes/" + testSandboxID + "/pause", + "POST /sandboxes/" + testSandboxID + "/resume", + "DELETE /sandboxes/" + testSandboxID, + } + if strings.Join(calls, "\n") != strings.Join(want, "\n") { + t.Fatalf("calls:\n%s\nwant:\n%s", strings.Join(calls, "\n"), strings.Join(want, "\n")) + } +} + +func TestAPIErrorMapping(t *testing.T) { + tests := []struct { + name string + statusCode int + body string + target error + call func(*Client) error + }{ + { + name: "authentication", + statusCode: http.StatusUnauthorized, + body: `{"message":"bad key"}`, + target: ErrAuthentication, + call: func(c *Client) error { + _, err := c.Health(context.Background()) + return err + }, + }, + { + name: "template not found", + statusCode: http.StatusNotFound, + body: `{"message":"template not found"}`, + target: ErrTemplateNotFound, + call: func(c *Client) error { + _, err := c.Create(context.Background(), CreateOptions{}) + return err + }, + }, + { + name: "template not found in backend 500", + statusCode: http.StatusInternalServerError, + body: `{"message":"CubeMaster returned error code 130404: failed to get template param from store: template not found"}`, + target: ErrTemplateNotFound, + call: func(c *Client) error { + _, err := c.Create(context.Background(), CreateOptions{}) + return err + }, + }, + { + name: "sandbox not found", + statusCode: http.StatusNotFound, + body: `{"message":"sandbox not found"}`, + target: ErrSandboxNotFound, + call: func(c *Client) error { + _, err := c.Connect(context.Background(), testSandboxID) + return err + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(tt.statusCode) + fmt.Fprint(w, tt.body) + })) + defer server.Close() + + client := NewClient(Config{APIURL: server.URL, TemplateID: "tpl-test"}) + err := tt.call(client) + if !errors.Is(err, tt.target) { + t.Fatalf("errors.Is(%v, %v)=false", err, tt.target) + } + var apiErr *APIError + if !errors.As(err, &apiErr) || apiErr.StatusCode != tt.statusCode { + t.Fatalf("APIError mismatch: %#v", err) + } + }) + } +} + +func TestParseLine(t *testing.T) { + execution := &Execution{} + var stdoutCalls, stderrCalls, resultCalls, errorCalls int + opts := RunCodeOptions{ + OnStdout: func(message OutputMessage) { + stdoutCalls++ + if message.Text != "hello\n" { + t.Fatalf("stdout callback text=%q", message.Text) + } + }, + OnStderr: func(message OutputMessage) { + stderrCalls++ + if !message.IsStderr { + t.Fatal("stderr callback IsStderr=false") + } + }, + OnResult: func(result Result) { + resultCalls++ + if result.Text != "42" { + t.Fatalf("result callback text=%q", result.Text) + } + }, + OnError: func(execErr ExecutionError) { + errorCalls++ + if execErr.Name != "ValueError" { + t.Fatalf("error callback name=%q", execErr.Name) + } + }, + } + + parseLine(execution, []byte(`{"type":"stdout","text":"hello\n","timestamp":"t1"}`), opts) + parseLine(execution, []byte(`{"type":"stderr","text":"warn\n","timestamp":"t2"}`), opts) + parseLine(execution, []byte(`{"type":"result","text":"42","is_main_result":true}`), opts) + parseLine(execution, []byte(`{"type":"error","name":"ValueError","value":"bad","traceback":["l1"]}`), opts) + parseLine(execution, []byte(`{"type":"number_of_executions","execution_count":5}`), opts) + parseLine(execution, []byte(`not json`), opts) + parseLine(execution, []byte(`{"type":"unknown","text":"ignored"}`), opts) + + if execution.Text != "42" || execution.Logs.Stdout[0] != "hello\n" || execution.Logs.Stderr[0] != "warn\n" { + t.Fatalf("execution mismatch: %#v", execution) + } + if execution.Error == nil || execution.Error.Value != "bad" { + t.Fatalf("error mismatch: %#v", execution.Error) + } + if execution.ExecutionCount == nil || *execution.ExecutionCount != 5 { + t.Fatalf("execution count mismatch: %#v", execution.ExecutionCount) + } + if stdoutCalls != 1 || stderrCalls != 1 || resultCalls != 1 || errorCalls != 1 { + t.Fatalf("callback counts=%d/%d/%d/%d", stdoutCalls, stderrCalls, resultCalls, errorCalls) + } +} + +func TestParseLineAcceptsStringTraceback(t *testing.T) { + execution := &Execution{} + parseLine(execution, []byte(`{"type":"error","name":"ValueError","value":"bad","traceback":"trace text"}`), RunCodeOptions{}) + + if execution.Error == nil { + t.Fatal("error event was not parsed") + } + if len(execution.Error.Traceback) != 1 || execution.Error.Traceback[0] != "trace text" { + t.Fatalf("traceback=%#v", execution.Error.Traceback) + } +} + +func TestRunCodeUsesProxyNodeIPAndPreservesHost(t *testing.T) { + var gotHost string + var gotPayload map[string]any + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/execute" { + t.Fatalf("request=%s %s", r.Method, r.URL.Path) + } + gotHost = r.Host + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + t.Fatalf("decode payload: %v", err) + } + w.Header().Set("Content-Type", "application/x-ndjson") + fmt.Fprintln(w, `{"type":"stdout","text":"out\n","timestamp":"t1"}`) + fmt.Fprintln(w, `{"type":"stderr","text":"err\n","timestamp":"t2"}`) + fmt.Fprintln(w, `{"type":"result","text":"ok","is_main_result":true}`) + fmt.Fprintln(w, `{"type":"number_of_executions","execution_count":7}`) + fmt.Fprintln(w, `not json`) + })) + defer server.Close() + + host, port := serverHostPort(t, server.URL) + client := NewClient(Config{ + ProxyNodeIP: host, + ProxyPortHTTP: port, + SandboxDomain: "cube.test", + RequestTimeout: time.Second, + Timeout: 300 * time.Second, + }) + sb := &Sandbox{client: client, SandboxID: "sb-proxy", TemplateID: "tpl-test"} + + var stdout []string + execution, err := sb.RunCode(context.Background(), "1 + 1", RunCodeOptions{ + Language: "python", + Envs: map[string]string{"A": "B"}, + OnStdout: func(message OutputMessage) { + stdout = append(stdout, message.Text) + }, + }) + if err != nil { + t.Fatalf("RunCode: %v", err) + } + + if gotHost != "49999-sb-proxy.cube.test" { + t.Fatalf("Host=%q", gotHost) + } + assertString(t, gotPayload, "code", "1 + 1") + assertString(t, gotPayload, "language", "python") + assertMapString(t, gotPayload["env_vars"], "A", "B") + if execution.Text != "ok" || execution.Logs.Stderr[0] != "err\n" || *execution.ExecutionCount != 7 { + t.Fatalf("execution=%#v", execution) + } + if strings.Join(stdout, "") != "out\n" { + t.Fatalf("stdout callback=%#v", stdout) + } +} + +func TestRunCodeUsesConfiguredProxyScheme(t *testing.T) { + var gotScheme string + client := NewClient(Config{ + ProxyScheme: "https", + }, WithHTTPClient(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotScheme = req.URL.Scheme + return &http.Response{ + StatusCode: http.StatusOK, + Body: http.NoBody, + Request: req, + }, nil + })})) + sb := &Sandbox{client: client, SandboxID: "sb-scheme", Domain: "cube.test"} + + if _, err := sb.RunCode(context.Background(), "1", RunCodeOptions{}); err != nil { + t.Fatalf("RunCode: %v", err) + } + if gotScheme != "https" { + t.Fatalf("scheme=%q", gotScheme) + } +} + +func TestCommandsRun(t *testing.T) { + starter := &fakeProcessStarter{ + result: &processStartResult{ + Stdout: "hello\nworld\n", + Stderr: "warn\n", + ExitCode: 0, + }, + } + commands := &Commands{starter: starter} + + result, err := commands.Run(context.Background(), "echo hello", CommandOptions{ + Timeout: 5 * time.Second, + Envs: map[string]string{"A": "B"}, + Cwd: "/work", + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + if starter.payload.Process.Cmd != "/bin/bash" { + t.Fatalf("process cmd=%q", starter.payload.Process.Cmd) + } + if got := strings.Join(starter.payload.Process.Args, "\x00"); got != "-l\x00-c\x00echo hello" { + t.Fatalf("process args=%#v", starter.payload.Process.Args) + } + if starter.payload.Process.Envs["A"] != "B" || starter.payload.Process.Cwd != "/work" { + t.Fatalf("process env/cwd mismatch: %#v", starter.payload.Process) + } + if starter.payload.Stdin == nil || *starter.payload.Stdin { + t.Fatalf("stdin=%v, want false", starter.payload.Stdin) + } + if starter.opts.Timeout != 5*time.Second { + t.Fatalf("timeout=%s", starter.opts.Timeout) + } + if result.Stdout != "hello\nworld\n" || result.Stderr != "warn\n" || result.ExitCode != 0 { + t.Fatalf("result=%#v", result) + } + + starter = &fakeProcessStarter{ + result: &processStartResult{ExitCode: 1}, + } + result, err = (&Commands{starter: starter}).Run(context.Background(), "false", CommandOptions{}) + if err != nil { + t.Fatalf("Run false: %v", err) + } + if result.ExitCode != 1 { + t.Fatalf("exit code=%d", result.ExitCode) + } + + starter = &fakeProcessStarter{ + result: &processStartResult{Stdout: "42\n"}, + } + result, err = (&Commands{starter: starter}).Run(context.Background(), "echo 42", CommandOptions{}) + if err != nil { + t.Fatalf("Run numeric stdout: %v", err) + } + if result.Stdout != "42\n" || result.ExitCode != 0 { + t.Fatalf("numeric stdout result=%#v", result) + } +} + +func TestFilesRead(t *testing.T) { + reader := &fakeFileReader{content: "file content"} + content, err := (&Files{reader: reader}).Read(context.Background(), "/tmp/foo.txt") + if err != nil { + t.Fatalf("Read: %v", err) + } + if content != "file content" { + t.Fatalf("content=%q", content) + } + if reader.path != "/tmp/foo.txt" { + t.Fatalf("path=%q", reader.path) + } + + reader = &fakeFileReader{} + content, err = (&Files{reader: reader}).Read(context.Background(), "/tmp/empty.txt") + if err != nil || content != "" { + t.Fatalf("empty content=%q err=%v", content, err) + } + + reader = &fakeFileReader{err: fmt.Errorf("failed to read /tmp/missing.txt: No such file")} + _, err = (&Files{reader: reader}).Read(context.Background(), "/tmp/missing.txt") + if err == nil || !strings.Contains(err.Error(), "failed to read /tmp/missing.txt: No such file") { + t.Fatalf("expected read error, got %v", err) + } +} + +func TestCommandsRunUsesEnvdProcessStart(t *testing.T) { + var gotHost string + var gotPayload map[string]any + var gotHeaders http.Header + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/process.Process/Start" { + t.Fatalf("request=%s %s", r.Method, r.URL.Path) + } + gotHost = r.Host + gotHeaders = r.Header.Clone() + if err := json.NewDecoder(r.Body).Decode(&gotPayload); err != nil { + t.Fatalf("decode payload: %v", err) + } + w.Header().Set("Content-Type", connectContentType) + w.Write(connectEnvelope(0, `{"event":{"start":{"pid":123}}}`)) + w.Write(connectEnvelope(0, fmt.Sprintf(`{"event":{"data":{"stdout":%q}}}`, base64.StdEncoding.EncodeToString([]byte("cmd-out\n"))))) + w.Write(connectEnvelope(0, fmt.Sprintf(`{"event":{"data":{"stderr":%q}}}`, base64.StdEncoding.EncodeToString([]byte("cmd-err\n"))))) + w.Write(connectEnvelope(0, `{"event":{"end":{"exitCode":7,"exited":true,"status":"exited"}}}`)) + w.Write(connectEnvelope(connectEndStreamFlag, `{}`)) + })) + defer server.Close() + + host, port := serverHostPort(t, server.URL) + client := NewClient(Config{ + ProxyNodeIP: host, + ProxyPortHTTP: port, + SandboxDomain: "cube.test", + RequestTimeout: time.Second, + }) + sb := &Sandbox{ + client: client, + SandboxID: "sb-proc", + TemplateID: "tpl-test", + EnvdAccessToken: "envd-token", + } + + result, err := sb.Commands().Run(context.Background(), "echo hello", CommandOptions{ + Timeout: 1500 * time.Millisecond, + Envs: map[string]string{"A": "B"}, + Cwd: "/work", + }) + if err != nil { + t.Fatalf("Run: %v", err) + } + + if gotHost != "49999-sb-proc.cube.test" { + t.Fatalf("Host=%q", gotHost) + } + if gotHeaders.Get("Content-Type") != connectContentType || gotHeaders.Get("Connect-Protocol-Version") != connectProtocolVersion { + t.Fatalf("connect headers=%#v", gotHeaders) + } + if gotHeaders.Get("Connect-Timeout-Ms") != "1500" || gotHeaders.Get("X-Access-Token") != "envd-token" { + t.Fatalf("headers=%#v", gotHeaders) + } + + processPayload, ok := gotPayload["process"].(map[string]any) + if !ok { + t.Fatalf("process payload=%#v", gotPayload["process"]) + } + assertString(t, processPayload, "cmd", "/bin/bash") + assertString(t, processPayload, "cwd", "/work") + args, ok := processPayload["args"].([]any) + if !ok || len(args) != 3 || args[0] != "-l" || args[1] != "-c" || args[2] != "echo hello" { + t.Fatalf("args=%#v", processPayload["args"]) + } + assertMapString(t, processPayload["envs"], "A", "B") + if gotPayload["stdin"] != false { + t.Fatalf("stdin=%#v", gotPayload["stdin"]) + } + if result.Stdout != "cmd-out\n" || result.Stderr != "cmd-err\n" || result.ExitCode != 7 { + t.Fatalf("result=%#v", result) + } +} + +func TestFilesReadUsesEnvdHTTPFileAPI(t *testing.T) { + var gotHost string + var gotPath string + var gotToken string + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet || r.URL.Path != "/files" { + t.Fatalf("request=%s %s", r.Method, r.URL.Path) + } + gotHost = r.Host + gotPath = r.URL.Query().Get("path") + gotToken = r.Header.Get("X-Access-Token") + fmt.Fprint(w, "file content") + })) + defer server.Close() + + host, port := serverHostPort(t, server.URL) + client := NewClient(Config{ + ProxyNodeIP: host, + ProxyPortHTTP: port, + SandboxDomain: "cube.test", + RequestTimeout: time.Second, + }) + sb := &Sandbox{ + client: client, + SandboxID: "sb-files", + TemplateID: "tpl-test", + EnvdAccessToken: "envd-token", + } + + content, err := sb.Files().Read(context.Background(), "/tmp/foo bar.txt") + if err != nil { + t.Fatalf("Read: %v", err) + } + if content != "file content" { + t.Fatalf("content=%q", content) + } + if gotHost != "49999-sb-files.cube.test" || gotPath != "/tmp/foo bar.txt" || gotToken != "envd-token" { + t.Fatalf("host/path/token=%q/%q/%q", gotHost, gotPath, gotToken) + } +} + +func TestFilesReadReturnsEnvdFileError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, `{"message":"file not found"}`, http.StatusNotFound) + })) + defer server.Close() + + host, port := serverHostPort(t, server.URL) + client := NewClient(Config{ + ProxyNodeIP: host, + ProxyPortHTTP: port, + SandboxDomain: "cube.test", + RequestTimeout: time.Second, + }) + sb := &Sandbox{client: client, SandboxID: "sb-files", TemplateID: "tpl-test"} + + _, err := sb.Files().Read(context.Background(), "/tmp/missing.txt") + if err == nil || !strings.Contains(err.Error(), "failed to read /tmp/missing.txt") || !strings.Contains(err.Error(), "file not found") { + t.Fatalf("error=%v", err) + } + if errors.Is(err, ErrSandboxNotFound) { + t.Fatalf("file read 404 should not be classified as sandbox not found: %v", err) + } +} + +type fakeRunner struct { + code string + opts RunCodeOptions + stdoutCallbacks []string + execution *Execution + err error +} + +func (r *fakeRunner) RunCode(_ context.Context, code string, opts RunCodeOptions) (*Execution, error) { + r.code = code + r.opts = opts + for _, text := range r.stdoutCallbacks { + if opts.OnStdout != nil { + opts.OnStdout(OutputMessage{Text: text}) + } + } + if r.execution == nil { + r.execution = &Execution{} + } + return r.execution, r.err +} + +type fakeProcessStarter struct { + payload processStartRequest + opts CommandOptions + result *processStartResult + err error +} + +func (s *fakeProcessStarter) startProcess(_ context.Context, payload processStartRequest, opts CommandOptions) (*processStartResult, error) { + s.payload = payload + s.opts = opts + if s.result == nil { + s.result = &processStartResult{} + } + return s.result, s.err +} + +type fakeFileReader struct { + path string + content string + err error +} + +func (r *fakeFileReader) readFile(_ context.Context, path string) (string, error) { + r.path = path + return r.content, r.err +} + +func connectEnvelope(flags byte, payload string) []byte { + frame := make([]byte, 5+len(payload)) + frame[0] = flags + binary.BigEndian.PutUint32(frame[1:5], uint32(len(payload))) + copy(frame[5:], payload) + return frame +} + +func clearEnv(t *testing.T) { + t.Helper() + for _, key := range []string{ + "CUBE_API_URL", + "CUBE_API_KEY", + "CUBE_TEMPLATE_ID", + "CUBE_PROXY_NODE_IP", + "CUBE_PROXY_PORT_HTTP", + "CUBE_PROXY_SCHEME", + "CUBE_SANDBOX_DOMAIN", + "CUBE_TIMEOUT", + "CUBE_REQUEST_TIMEOUT", + "E2B_API_URL", + "E2B_API_KEY", + } { + t.Setenv(key, "") + } +} + +func sandboxJSON(sandboxID, templateID string) string { + return fmt.Sprintf(`{"sandboxID":%q,"templateID":%q,"clientID":"client-1","envdVersion":"0.0.1","domain":"cube.app"}`, sandboxID, templateID) +} + +func sandboxInfoJSON(sandboxID, state string) string { + return fmt.Sprintf(`{"sandboxID":%q,"templateID":"tpl-test","clientID":"client-1","startedAt":"2026-05-14T00:00:00Z","endAt":"2026-05-14T01:00:00Z","envdVersion":"0.0.1","domain":"cube.app","cpuCount":2,"memoryMB":512,"state":%q}`, sandboxID, state) +} + +func serverHostPort(t *testing.T, rawURL string) (string, int) { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatalf("parse server URL: %v", err) + } + host, portString, err := net.SplitHostPort(parsed.Host) + if err != nil { + t.Fatalf("split host port: %v", err) + } + var port int + if _, err := fmt.Sscanf(portString, "%d", &port); err != nil { + t.Fatalf("parse port: %v", err) + } + return host, port +} + +func assertString(t *testing.T, values map[string]any, key, want string) { + t.Helper() + if values[key] != want { + t.Fatalf("%s=%#v, want %q", key, values[key], want) + } +} + +func assertNumber(t *testing.T, values map[string]any, key string, want float64) { + t.Helper() + if values[key] != want { + t.Fatalf("%s=%#v, want %v", key, values[key], want) + } +} + +func assertMapString(t *testing.T, value any, key, want string) { + t.Helper() + values, ok := value.(map[string]any) + if !ok { + t.Fatalf("value=%#v, want map", value) + } + if values[key] != want { + t.Fatalf("%s=%#v, want %q", key, values[key], want) + } +} + +func assertStringSlice(t *testing.T, value any, want []string) { + t.Helper() + raw, ok := value.([]any) + if !ok { + t.Fatalf("value=%#v, want slice", value) + } + got := make([]string, 0, len(raw)) + for _, item := range raw { + got = append(got, item.(string)) + } + if strings.Join(got, ",") != strings.Join(want, ",") { + t.Fatalf("slice=%#v, want %#v", got, want) + } +} diff --git a/sdk/go/stream.go b/sdk/go/stream.go new file mode 100644 index 00000000..abe2cee0 --- /dev/null +++ b/sdk/go/stream.go @@ -0,0 +1,124 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "bufio" + "encoding/json" + "io" +) + +func parseStream(r io.Reader, execution *Execution, opts RunCodeOptions) error { + scanner := bufio.NewScanner(r) + scanner.Buffer(make([]byte, 0, 64*1024), 16*1024*1024) + for scanner.Scan() { + parseLine(execution, scanner.Bytes(), opts) + } + return scanner.Err() +} + +func parseLine(execution *Execution, line []byte, opts RunCodeOptions) { + if len(line) == 0 { + return + } + + var envelope map[string]json.RawMessage + if err := json.Unmarshal(line, &envelope); err != nil { + return + } + + var eventType string + if err := json.Unmarshal(envelope["type"], &eventType); err != nil { + return + } + + switch eventType { + case "result": + var result Result + if err := json.Unmarshal(line, &result); err != nil { + return + } + execution.Results = append(execution.Results, result) + if result.IsMainResult { + execution.Text = result.Text + } + if opts.OnResult != nil { + opts.OnResult(result) + } + case "stdout": + var event struct { + Text string `json:"text"` + Timestamp string `json:"timestamp"` + } + if err := json.Unmarshal(line, &event); err != nil { + return + } + execution.Logs.Stdout = append(execution.Logs.Stdout, event.Text) + if opts.OnStdout != nil { + opts.OnStdout(OutputMessage{Text: event.Text, Timestamp: event.Timestamp}) + } + case "stderr": + var event struct { + Text string `json:"text"` + Timestamp string `json:"timestamp"` + } + if err := json.Unmarshal(line, &event); err != nil { + return + } + execution.Logs.Stderr = append(execution.Logs.Stderr, event.Text) + if opts.OnStderr != nil { + opts.OnStderr(OutputMessage{Text: event.Text, Timestamp: event.Timestamp, IsStderr: true}) + } + case "error": + var event struct { + Name string `json:"name"` + Value string `json:"value"` + Traceback json.RawMessage `json:"traceback"` + } + if err := json.Unmarshal(line, &event); err != nil { + return + } + execErr := ExecutionError{ + Name: event.Name, + Value: event.Value, + Traceback: parseTraceback(event.Traceback), + } + if execErr.Traceback == nil { + execErr.Traceback = []string{} + } + execution.Error = &execErr + if opts.OnError != nil { + opts.OnError(execErr) + } + case "number_of_executions": + var event struct { + ExecutionCount int `json:"execution_count"` + } + if err := json.Unmarshal(line, &event); err != nil { + return + } + execution.ExecutionCount = &event.ExecutionCount + } +} + +func parseTraceback(raw json.RawMessage) []string { + if len(raw) == 0 || string(raw) == "null" { + return nil + } + + var lines []string + if err := json.Unmarshal(raw, &lines); err == nil { + return lines + } + + var text string + if err := json.Unmarshal(raw, &text); err == nil { + if text == "" { + return nil + } + return []string{text} + } + + return nil +} diff --git a/sdk/go/transport.go b/sdk/go/transport.go new file mode 100644 index 00000000..f3fd0916 --- /dev/null +++ b/sdk/go/transport.go @@ -0,0 +1,38 @@ +// Copyright (c) 2026 Tencent Inc. +// SPDX-License-Identifier: Apache-2.0 + +package cubesandbox + +import ( + "context" + "net" + "net/http" + "strconv" + "time" +) + +func newControlHTTPClient(cfg Config) *http.Client { + return &http.Client{Timeout: cfg.RequestTimeout} +} + +func newDataHTTPClient(cfg Config) *http.Client { + transport := http.DefaultTransport.(*http.Transport).Clone() + transport.Proxy = nil + transport.DialContext = (&net.Dialer{ + Timeout: cfg.RequestTimeout, + KeepAlive: 30 * time.Second, + }).DialContext + + if cfg.ProxyNodeIP != "" { + target := net.JoinHostPort(cfg.ProxyNodeIP, strconv.Itoa(cfg.ProxyPortHTTP)) + dialer := &net.Dialer{ + Timeout: cfg.RequestTimeout, + KeepAlive: 30 * time.Second, + } + transport.DialContext = func(ctx context.Context, network, _ string) (net.Conn, error) { + return dialer.DialContext(ctx, network, target) + } + } + + return &http.Client{Transport: transport} +}