diff --git a/README.md b/README.md index 7272637..9868ae0 100644 --- a/README.md +++ b/README.md @@ -61,6 +61,50 @@ func main() { } ``` +## Unified client + +When you create or manage more than a couple of sandboxes, hold a single +`e2b.Client`. It resolves the config once and shares one HTTP client and one +control-plane REST client across every call and every sandbox it creates: + +```go +c, err := e2b.NewClient(e2b.Config{}) // reads credentials from the env +if err != nil { + log.Fatal(err) +} + +sbx, err := c.Create(ctx, e2b.CreateOptions{Template: "base"}) +if err != nil { + log.Fatal(err) +} +defer sbx.Kill(ctx) + +// List all running sandboxes (paginated under the hood). +running, err := c.ListAll(ctx, e2b.SandboxListOptions{ + State: []e2b.SandboxState{e2b.SandboxStateRunning}, +}) +if err != nil { + log.Fatal(err) +} +for _, s := range running { + fmt.Println(s.SandboxID, s.State) +} + +// Or page manually: +p := c.List(ctx, e2b.SandboxListOptions{}) +for p.HasNext() { + page, err := p.NextItems(ctx) + if err != nil { + log.Fatal(err) + } + // ... use page ... +} +``` + +The package-level `e2b.Create` / `Connect` / `Kill` / `List` / `ListAll` +functions still work; they build a throwaway `Client` per call and are kept for +compatibility. Prefer `NewClient` to reuse the REST client across calls. + ## Authentication The SDK reads credentials from the environment: @@ -176,7 +220,7 @@ CI is split across two workflows: v1 implements the core sandbox surface: -- [x] `Create`, `Connect`, `Kill`, `Pause`, `CreateSnapshot`, `GetInfo`, `GetMetrics`, `SetTimeout` +- [x] `Create`, `Connect`, `Kill`, `List` / `ListAll`, `Pause`, `CreateSnapshot`, `GetInfo`, `GetMetrics`, `SetTimeout` - [x] `Commands.Run` / `Start` / `Connect` / `List` / `Kill` / `SendStdin` / `CloseStdin` - [x] `Pty.Create` / `Resize` / `SendInput` / `Kill` - [x] `Filesystem` `Read` / `Write` / `List` / `Stat` / `Move` / `Remove` / `MakeDir` / `Watch` diff --git a/client.go b/client.go new file mode 100644 index 0000000..79d594c --- /dev/null +++ b/client.go @@ -0,0 +1,140 @@ +package e2b + +import ( + "context" + "maps" + "net/http" + "time" + + apiclient "github.com/eric642/e2b-go-sdk/internal/api" + "github.com/eric642/e2b-go-sdk/internal/transport" +) + +// Client is the unified entry point to the E2B control plane. It resolves a +// Config once and constructs a single shared HTTP client and control-plane +// REST client, both reused across every call and by every Sandbox it creates. +// +// Prefer holding one *Client for the lifetime of your program (or per credential +// set) over the package-level Create/Connect/Kill helpers, which build a +// throwaway Client per call and so cannot reuse the REST client across calls. +// +// A *Client is safe for concurrent use. +type Client struct { + cfg Config // already resolved + apiCli *apiclient.Client // control-plane REST client, built once + httpCli *http.Client // shared HTTP client, built once +} + +// NewClient resolves cfg (applying environment-variable fallbacks and +// defaults) and builds the shared HTTP and control-plane REST clients. Every +// Sandbox created through the returned Client reuses them. +// +// A zero Config is valid; credentials and domain fall back to the environment. +func NewClient(cfg Config) (*Client, error) { + resolved := cfg.resolve() + hc := resolved.httpClient() + auth := transport.Auth{APIKey: resolved.APIKey, AccessToken: resolved.AccessToken, Headers: resolved.Headers} + apiCli, err := transport.NewAPIClient(resolved.APIURL, hc, auth) + if err != nil { + return nil, newSandboxError("init api client", err) + } + return &Client{cfg: resolved, apiCli: apiCli, httpCli: hc}, nil +} + +// Config returns a copy of the resolved Config backing this Client. The map +// fields (Headers, ExtraSandboxHeaders) are deep-copied so mutating the +// returned Config cannot alter this Client's live request headers or race with +// concurrent requests. +func (c *Client) Config() Config { + out := c.cfg + if c.cfg.Headers != nil { + out.Headers = maps.Clone(c.cfg.Headers) + } + if c.cfg.ExtraSandboxHeaders != nil { + out.ExtraSandboxHeaders = maps.Clone(c.cfg.ExtraSandboxHeaders) + } + return out +} + +// Create provisions a new sandbox and returns a *Sandbox wired up with all +// sub-clients, sharing this Client's HTTP and REST clients. The caller owns +// the returned sandbox and should call Kill (directly or via defer) to release +// it. +// +// opts.Config is ignored: the Client already owns the resolved configuration. +// Use a separate Client (via NewClient) for a different credential set. +func (c *Client) Create(ctx context.Context, opts CreateOptions) (*Sandbox, error) { + body := buildNewSandbox(opts) + resp, err := c.apiCli.PostSandboxes(ctx, body) + if err != nil { + return nil, mapHTTPOrCtx(err) + } + defer resp.Body.Close() + if err := mapHTTPErr(resp, ""); err != nil { + return nil, err + } + parsed, err := apiclient.ParsePostSandboxesResponse(resp) + if err != nil { + return nil, newSandboxError("parse create response", err) + } + created := parsed.JSON201 + if created == nil { + return nil, newSandboxError("empty create response body", nil) + } + return c.newSandbox(created) +} + +// Connect attaches to an existing sandbox (running or paused). If paused, the +// server resumes it; the call sets the sandbox timeout to opts.Timeout +// (default 5 minutes). +// +// opts.Config is ignored (see Create). +func (c *Client) Connect(ctx context.Context, sandboxID string, opts ConnectOptions) (*Sandbox, error) { + timeout := opts.Timeout + if timeout <= 0 { + timeout = DefaultSandboxTimeout + } + body := apiclient.ConnectSandbox{Timeout: int32(timeout / time.Second)} + resp, err := c.apiCli.PostSandboxesSandboxIDConnect(ctx, sandboxID, body) + if err != nil { + return nil, mapHTTPOrCtx(err) + } + defer resp.Body.Close() + if err := mapHTTPErr(resp, sandboxID); err != nil { + return nil, err + } + parsed, err := apiclient.ParsePostSandboxesSandboxIDConnectResponse(resp) + if err != nil { + return nil, newSandboxError("parse connect response", err) + } + var connected *apiclient.Sandbox + if parsed.JSON201 != nil { + connected = parsed.JSON201 + } else if parsed.JSON200 != nil { + connected = parsed.JSON200 + } + if connected == nil { + return nil, newSandboxError("empty connect response body", nil) + } + return c.newSandbox(connected) +} + +// Kill terminates a sandbox by ID. Returns false (nil error) if the sandbox +// was already gone. +func (c *Client) Kill(ctx context.Context, sandboxID string) (bool, error) { + if c.cfg.Debug { + return true, nil + } + resp, err := c.apiCli.DeleteSandboxesSandboxID(ctx, sandboxID) + if err != nil { + return false, mapHTTPOrCtx(err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotFound { + return false, nil + } + if err := mapHTTPErr(resp, sandboxID); err != nil { + return false, err + } + return true, nil +} diff --git a/client_test.go b/client_test.go new file mode 100644 index 0000000..9030374 --- /dev/null +++ b/client_test.go @@ -0,0 +1,325 @@ +package e2b + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + apiclient "github.com/eric642/e2b-go-sdk/internal/api" +) + +// fakeListedSandbox builds a ListedSandbox JSON body element. Times are +// RFC3339 so the generated time.Time fields parse. +func fakeListedSandbox(id string) map[string]any { + return map[string]any{ + "sandboxID": id, + "clientID": "client-1", + "templateID": "base", + "envdVersion": "v1.2.3", + "cpuCount": 2, + "memoryMB": 512, + "diskSizeMB": 1024, + "startedAt": "2026-01-01T00:00:00Z", + "endAt": "2026-01-01T01:00:00Z", + "state": "running", + } +} + +func TestNewClientResolvesConfig(t *testing.T) { + mock := newRESTMock(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + if c.Config().APIURL == "" { + t.Fatal("resolved APIURL should be non-empty") + } + if ua := c.Config().Headers["User-Agent"]; ua == "" { + t.Fatal("resolved config should set a default User-Agent header") + } +} + +func TestConfigReturnsDeepCopyOfMaps(t *testing.T) { + mock := newRESTMock(t, http.HandlerFunc(func(http.ResponseWriter, *http.Request) {})) + mock.Config.Headers = map[string]string{"X-Custom": "v1"} + mock.Config.ExtraSandboxHeaders = map[string]string{"X-Sbx": "s1"} + + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + + // Mutating the returned Config must not leak into the Client's live maps, + // which the request editor reads on every call. + got := c.Config() + got.Headers["X-Custom"] = "tampered" + got.Headers["X-Injected"] = "nope" + delete(got.ExtraSandboxHeaders, "X-Sbx") + + if c.cfg.Headers["X-Custom"] != "v1" { + t.Errorf("live Headers mutated: %q", c.cfg.Headers["X-Custom"]) + } + if _, ok := c.cfg.Headers["X-Injected"]; ok { + t.Error("live Headers gained an injected key") + } + if c.cfg.ExtraSandboxHeaders["X-Sbx"] != "s1" { + t.Error("live ExtraSandboxHeaders was modified") + } +} + +func TestClientReusesAPIClientAcrossCreates(t *testing.T) { + var n int + mock := newRESTMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertAuthHeader(t, r, "test-key") + n++ + writeJSON(t, w, http.StatusCreated, fakeSandboxResponse("sbx-"+itoa(n), "example.com", "", "")) + })) + + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + sbx1, err := c.Create(context.Background(), CreateOptions{}) + if err != nil { + t.Fatalf("Create #1: %v", err) + } + sbx2, err := c.Create(context.Background(), CreateOptions{}) + if err != nil { + t.Fatalf("Create #2: %v", err) + } + + if sbx1.apiCli != sbx2.apiCli { + t.Error("two sandboxes from one Client should share the same *apiclient.Client") + } + if sbx1.httpCli != sbx2.httpCli { + t.Error("two sandboxes from one Client should share the same *http.Client") + } + if sbx1.apiCli != c.apiCli || sbx1.httpCli != c.httpCli { + t.Error("sandbox clients should be the Client's shared clients") + } +} + +func TestClientCreateIgnoresOptsConfig(t *testing.T) { + hit := false + mock := newRESTMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hit = true + writeJSON(t, w, http.StatusCreated, fakeSandboxResponse("sbx-1", "example.com", "", "")) + })) + + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + // A bogus APIURL in opts.Config must be ignored: the request still hits + // the mock server backing the Client. + if _, err := c.Create(context.Background(), CreateOptions{ + Config: Config{APIURL: "http://wrong.invalid"}, + }); err != nil { + t.Fatalf("Create: %v", err) + } + if !hit { + t.Fatal("request should have hit the Client's server, not opts.Config.APIURL") + } +} + +func TestListPaginatesViaNextTokenHeader(t *testing.T) { + mock := newRESTMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertAuthHeader(t, r, "test-key") + if r.URL.Path != "/v2/sandboxes" { + t.Errorf("path: %s", r.URL.Path) + } + switch r.URL.Query().Get("nextToken") { + case "": + w.Header().Set("x-next-token", "tok-2") + writeJSON(t, w, http.StatusOK, []map[string]any{fakeListedSandbox("sbx-1")}) + case "tok-2": + // no x-next-token header -> last page + writeJSON(t, w, http.StatusOK, []map[string]any{fakeListedSandbox("sbx-2")}) + default: + t.Errorf("unexpected nextToken: %q", r.URL.Query().Get("nextToken")) + } + })) + + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + p := c.List(context.Background(), SandboxListOptions{}) + + if !p.HasNext() { + t.Fatal("fresh paginator should report HasNext") + } + page1, err := p.NextItems(context.Background()) + if err != nil { + t.Fatalf("page1: %v", err) + } + if len(page1) != 1 || page1[0].SandboxID != "sbx-1" { + t.Fatalf("page1: %+v", page1) + } + if !p.HasNext() { + t.Fatal("should have a second page (x-next-token was set)") + } + page2, err := p.NextItems(context.Background()) + if err != nil { + t.Fatalf("page2: %v", err) + } + if len(page2) != 1 || page2[0].SandboxID != "sbx-2" { + t.Fatalf("page2: %+v", page2) + } + if p.HasNext() { + t.Fatal("should be exhausted after the last page") + } + tail, err := p.NextItems(context.Background()) + if err != nil || tail != nil { + t.Fatalf("exhausted NextItems should return (nil,nil), got (%+v,%v)", tail, err) + } +} + +func TestListAllDrainsPages(t *testing.T) { + mock := newRESTMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Query().Get("nextToken") { + case "": + w.Header().Set("x-next-token", "tok-2") + writeJSON(t, w, http.StatusOK, []map[string]any{fakeListedSandbox("sbx-1")}) + case "tok-2": + writeJSON(t, w, http.StatusOK, []map[string]any{fakeListedSandbox("sbx-2")}) + } + })) + + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + all, err := c.ListAll(context.Background(), SandboxListOptions{}) + if err != nil { + t.Fatalf("ListAll: %v", err) + } + if len(all) != 2 || all[0].SandboxID != "sbx-1" || all[1].SandboxID != "sbx-2" { + t.Fatalf("ListAll: %+v", all) + } +} + +func TestListSendsMetadataAndStateParams(t *testing.T) { + mock := newRESTMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + if got := q.Get("limit"); got != "50" { + t.Errorf("limit=%q want 50", got) + } + // State is form/explode=false -> comma-joined single value. + if states := q["state"]; len(states) == 0 || !strings.Contains(strings.Join(states, ","), "running") { + t.Errorf("state=%v want running", states) + } + // metadata arrives URL-decoded once by net/url; the inner per-pair + // encoding survives, so we still see "k=v" joined by "&". + md := q.Get("metadata") + if !strings.Contains(md, "user=abc") || !strings.Contains(md, "app=prod") { + t.Errorf("metadata=%q want user=abc & app=prod", md) + } + writeJSON(t, w, http.StatusOK, []map[string]any{}) + })) + + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + _, err = c.ListAll(context.Background(), SandboxListOptions{ + Metadata: map[string]string{"user": "abc", "app": "prod"}, + State: []SandboxState{SandboxStateRunning}, + Limit: 50, + }) + if err != nil { + t.Fatalf("ListAll: %v", err) + } +} + +func TestListMapsHTTPErrors(t *testing.T) { + cases := []struct { + status int + check func(error) bool + }{ + {http.StatusUnauthorized, func(e error) bool { var t *AuthenticationError; return errors.As(e, &t) }}, + {http.StatusTooManyRequests, func(e error) bool { var t *RateLimitError; return errors.As(e, &t) }}, + {http.StatusInternalServerError, func(e error) bool { var t *SandboxError; return errors.As(e, &t) }}, + } + for _, tc := range cases { + status := tc.status + mock := newRESTMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(status) + })) + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + _, err = c.ListAll(context.Background(), SandboxListOptions{}) + if err == nil || !tc.check(err) { + t.Errorf("status %d: unexpected error %T: %v", status, err, err) + } + } +} + +func TestListEmptyBody(t *testing.T) { + mock := newRESTMock(t, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + writeJSON(t, w, http.StatusOK, []map[string]any{}) + })) + c, err := NewClient(mock.Config) + if err != nil { + t.Fatalf("NewClient: %v", err) + } + p := c.List(context.Background(), SandboxListOptions{}) + page, err := p.NextItems(context.Background()) + if err != nil { + t.Fatalf("NextItems: %v", err) + } + if len(page) != 0 { + t.Fatalf("expected empty page, got %+v", page) + } + if p.HasNext() { + t.Fatal("no x-next-token -> should be exhausted") + } +} + +func TestSandboxInfoFromListed(t *testing.T) { + alias := "my-alias" + md := apiclient.SandboxMetadata{"team": "sdk"} + mounts := []apiclient.SandboxVolumeMount{{Name: "data", Path: "/mnt/data"}} + started := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + ended := time.Date(2026, 1, 1, 1, 0, 0, 0, time.UTC) + + d := &apiclient.ListedSandbox{ + SandboxID: "sbx-9", + TemplateID: "base", + State: apiclient.SandboxState("running"), + CpuCount: 2, + MemoryMB: 512, + DiskSizeMB: 1024, + StartedAt: started, + EndAt: ended, + EnvdVersion: "v1", + Alias: &alias, + Metadata: &md, + VolumeMounts: &mounts, + } + info := sandboxInfoFromListed(d) + if info.SandboxID != "sbx-9" || info.TemplateID != "base" || info.State != SandboxStateRunning { + t.Fatalf("core fields: %+v", info) + } + if info.CPUCount != 2 || info.MemoryMB != 512 || info.DiskSizeMB != 1024 { + t.Fatalf("sizes: %+v", info) + } + if !info.StartedAt.Equal(started) || !info.EndAt.Equal(ended) { + t.Fatalf("times: %+v", info) + } + if info.Alias != "my-alias" || info.Metadata["team"] != "sdk" { + t.Fatalf("alias/metadata: %+v", info) + } + if len(info.VolumeMounts) != 1 || info.VolumeMounts[0].Name != "data" { + t.Fatalf("mounts: %+v", info.VolumeMounts) + } + // Fields absent from ListedSandbox must stay zero. + if info.Domain != "" || info.EnvdAccessToken != "" || info.Network != nil || info.Lifecycle != nil { + t.Fatalf("absent fields should be zero: %+v", info) + } +} diff --git a/doc.go b/doc.go index 5906529..695902f 100644 --- a/doc.go +++ b/doc.go @@ -34,6 +34,26 @@ // // Pass an explicit Config to override. // +// # Unified client +// +// For programs that create or manage many sandboxes, build one Client and +// reuse it. It resolves the Config once and shares a single HTTP and +// control-plane REST client across every call and every Sandbox it creates: +// +// c, err := e2b.NewClient(e2b.Config{}) // reads credentials from the env +// if err != nil { +// log.Fatal(err) +// } +// sbx, err := c.Create(ctx, e2b.CreateOptions{Template: "base"}) +// // ... +// running, err := c.ListAll(ctx, e2b.SandboxListOptions{ +// State: []e2b.SandboxState{e2b.SandboxStateRunning}, +// }) +// +// The package-level Create, Connect, Kill, List and ListAll functions are thin +// convenience wrappers that build a throwaway Client per call; they are kept +// for compatibility but NewClient is preferred. +// // # Sub-packages // // - template: fluent builder for sandbox templates (serialization only in v1) diff --git a/examples/basic/main.go b/examples/basic/main.go index 5628ab8..7076f07 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -20,7 +20,12 @@ func main() { ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) defer cancel() - sbx, err := e2b.Create(ctx, e2b.CreateOptions{ + client, err := e2b.NewClient(e2b.Config{}) + if err != nil { + log.Fatalf("client: %v", err) + } + + sbx, err := client.Create(ctx, e2b.CreateOptions{ Template: envOr("E2B_TEMPLATE", "base"), Timeout: 5 * time.Minute, }) diff --git a/examples/desktop/main.go b/examples/desktop/main.go index f46f625..6196e38 100644 --- a/examples/desktop/main.go +++ b/examples/desktop/main.go @@ -109,8 +109,11 @@ func main() { // 2) Launch a sandbox and tear it down on exit. RequestTimeoutDisabled on // Config prevents the default 60s HTTP client from cutting the PTY // stream short. - sbx, err := e2b.Create(ctx, e2b.CreateOptions{ - Config: e2b.Config{RequestTimeoutDisabled: true}, + client, err := e2b.NewClient(e2b.Config{RequestTimeoutDisabled: true}) + if err != nil { + log.Fatalf("client: %v", err) + } + sbx, err := client.Create(ctx, e2b.CreateOptions{ Template: templateID, Timeout: 15 * time.Minute, Secure: true, diff --git a/examples/lifecycle/main.go b/examples/lifecycle/main.go index d95c127..1ba2ed6 100644 --- a/examples/lifecycle/main.go +++ b/examples/lifecycle/main.go @@ -94,7 +94,11 @@ func main() { } // 4) Start a sandbox from the template. - sbx, err := e2b.Create(ctx, e2b.CreateOptions{ + client, err := e2b.NewClient(e2b.Config{}) + if err != nil { + log.Fatalf("client: %v", err) + } + sbx, err := client.Create(ctx, e2b.CreateOptions{ Template: info.TemplateID, Timeout: 5 * time.Minute, }) diff --git a/examples/lifecycle_v2/main.go b/examples/lifecycle_v2/main.go index 7ce584b..7f19ace 100644 --- a/examples/lifecycle_v2/main.go +++ b/examples/lifecycle_v2/main.go @@ -96,7 +96,11 @@ func main() { } fmt.Printf("template visibility set via legacy PATCH /templates/{id}\n") - sbx, err := e2b.Create(ctx, e2b.CreateOptions{ + client, err := e2b.NewClient(e2b.Config{}) + if err != nil { + log.Fatalf("client: %v", err) + } + sbx, err := client.Create(ctx, e2b.CreateOptions{ Template: info.TemplateID, Timeout: 5 * time.Minute, }) diff --git a/examples/selfhosted/main.go b/examples/selfhosted/main.go index 420df87..8eba7a2 100644 --- a/examples/selfhosted/main.go +++ b/examples/selfhosted/main.go @@ -38,8 +38,12 @@ func main() { log.Fatal("E2B_API_KEY is required") } - sbx, err := e2b.Create(ctx, e2b.CreateOptions{ - Config: cfg, + client, err := e2b.NewClient(cfg) + if err != nil { + log.Fatalf("client: %v", err) + } + + sbx, err := client.Create(ctx, e2b.CreateOptions{ Template: envOr("E2B_TEMPLATE", "base"), Timeout: 5 * time.Minute, Metadata: map[string]string{"example": "selfhosted"}, diff --git a/examples/template/main.go b/examples/template/main.go index 296cd8f..6e69ebc 100644 --- a/examples/template/main.go +++ b/examples/template/main.go @@ -54,7 +54,11 @@ func main() { } fmt.Printf("template built: %s\n", info.TemplateID) - sbx, err := e2b.Create(ctx, e2b.CreateOptions{Template: info.TemplateID}) + client, err := e2b.NewClient(e2b.Config{}) + if err != nil { + log.Fatalf("client: %v", err) + } + sbx, err := client.Create(ctx, e2b.CreateOptions{Template: info.TemplateID}) if err != nil { log.Fatalf("create sandbox: %v", err) } diff --git a/examples/terminal/main.go b/examples/terminal/main.go index cbd7dfd..3daf840 100644 --- a/examples/terminal/main.go +++ b/examples/terminal/main.go @@ -42,8 +42,12 @@ func main() { ctx := context.Background() - sbx, err := e2b.Create(ctx, e2b.CreateOptions{ - Config: cfg, + client, err := e2b.NewClient(cfg) + if err != nil { + log.Fatalf("client: %v", err) + } + + sbx, err := client.Create(ctx, e2b.CreateOptions{ Template: envOr("E2B_TEMPLATE", "base"), Timeout: 30 * time.Minute, Metadata: map[string]string{"example": "terminal"}, diff --git a/list.go b/list.go new file mode 100644 index 0000000..9a28fa7 --- /dev/null +++ b/list.go @@ -0,0 +1,185 @@ +package e2b + +import ( + "context" + "net/url" + "sort" + "strings" + + apiclient "github.com/eric642/e2b-go-sdk/internal/api" +) + +// SandboxListOptions filters and paginates a sandbox list. The zero value +// lists all sandboxes the caller can see, one server-defined page at a time. +type SandboxListOptions struct { + // Metadata filters by exact key/value pairs (all must match). + Metadata map[string]string + // State filters by one or more states. Empty means all states. + State []SandboxState + // Limit caps the number of items per page. 0 uses the server default. + Limit int32 + // NextToken resumes from a previous page's cursor. Usually left empty; + // the paginator manages it internally. + NextToken string +} + +// SandboxPaginator iterates sandbox list pages, following the server's +// cursor (the x-next-token response header). Obtain one from Client.List. +// +// A SandboxPaginator is stateful and not safe for concurrent use. +type SandboxPaginator struct { + c *Client + opts SandboxListOptions + next string + hasNext bool +} + +// HasNext reports whether another page is available. It is true before the +// first NextItems call; afterwards it reflects the x-next-token header of the +// most recent response. +func (p *SandboxPaginator) HasNext() bool { return p.hasNext } + +// NextToken returns the cursor for the next page, or "" when exhausted. It can +// be persisted and replayed via SandboxListOptions.NextToken. +func (p *SandboxPaginator) NextToken() string { return p.next } + +// NextItems fetches the next page of sandboxes. Call it only while HasNext +// reports true; once exhausted it returns (nil, nil). After the call HasNext +// reflects whether further pages remain. +func (p *SandboxPaginator) NextItems(ctx context.Context) ([]SandboxInfo, error) { + if !p.hasNext { + return nil, nil + } + + params := &apiclient.GetV2SandboxesParams{} + if md := encodeMetadataQuery(p.opts.Metadata); md != "" { + params.Metadata = &md + } + if len(p.opts.State) > 0 { + states := make([]apiclient.SandboxState, 0, len(p.opts.State)) + for _, s := range p.opts.State { + states = append(states, apiclient.SandboxState(s)) + } + params.State = &states + } + if p.opts.Limit > 0 { + lim := apiclient.PaginationLimit(p.opts.Limit) + params.Limit = &lim + } + if p.next != "" { + tok := apiclient.PaginationNextToken(p.next) + params.NextToken = &tok + } + + resp, err := p.c.apiCli.GetV2Sandboxes(ctx, params) + if err != nil { + return nil, mapHTTPOrCtx(err) + } + defer resp.Body.Close() + // mapHTTPErr returns nil for 2xx without reading the body, leaving it for + // ParseGetV2SandboxesResponse below. On an error status it consumes the + // body, so we must not also parse it. + if err := mapHTTPErr(resp, ""); err != nil { + return nil, err + } + parsed, err := apiclient.ParseGetV2SandboxesResponse(resp) + if err != nil { + return nil, newSandboxError("parse list response", err) + } + + // Advance the cursor from the x-next-token response header. + token := "" + if parsed.HTTPResponse != nil { + token = parsed.HTTPResponse.Header.Get("x-next-token") + } + p.next = token + p.hasNext = token != "" + + if parsed.JSON200 == nil { + return nil, nil + } + listed := *parsed.JSON200 + out := make([]SandboxInfo, 0, len(listed)) + for i := range listed { + out = append(out, *sandboxInfoFromListed(&listed[i])) + } + return out, nil +} + +// List returns a paginator over the sandboxes visible to this Client. The +// request is not issued until the first NextItems call. +// +// Example: +// +// p := c.List(ctx, e2b.SandboxListOptions{State: []e2b.SandboxState{e2b.SandboxStateRunning}}) +// for p.HasNext() { +// page, err := p.NextItems(ctx) +// if err != nil { +// return err +// } +// for _, s := range page { +// fmt.Println(s.SandboxID) +// } +// } +// +// SandboxInfo records returned here are lighter than Sandbox.GetInfo: see +// sandboxInfoFromListed. +func (c *Client) List(_ context.Context, opts SandboxListOptions) *SandboxPaginator { + return &SandboxPaginator{c: c, opts: opts, next: opts.NextToken, hasNext: true} +} + +// ListAll drains every page and returns all matching sandboxes. Prefer List +// for large result sets where streaming pages avoids buffering everything. +func (c *Client) ListAll(ctx context.Context, opts SandboxListOptions) ([]SandboxInfo, error) { + p := c.List(ctx, opts) + var all []SandboxInfo + for p.HasNext() { + page, err := p.NextItems(ctx) + if err != nil { + return all, err + } + all = append(all, page...) + } + return all, nil +} + +// List returns a paginator over sandboxes using a one-shot Client built from +// cfg. +// +// Deprecated: prefer NewClient(cfg).List, which reuses one control-plane REST +// client across calls. +func List(ctx context.Context, cfg Config, opts SandboxListOptions) (*SandboxPaginator, error) { + c, err := NewClient(cfg) + if err != nil { + return nil, err + } + return c.List(ctx, opts), nil +} + +// ListAll drains every page using a one-shot Client built from cfg. +// +// Deprecated: prefer NewClient(cfg).ListAll (see List). +func ListAll(ctx context.Context, cfg Config, opts SandboxListOptions) ([]SandboxInfo, error) { + c, err := NewClient(cfg) + if err != nil { + return nil, err + } + return c.ListAll(ctx, opts) +} + +// encodeMetadataQuery builds the metadata filter the API expects: each key and +// value URL-encoded, joined as key=value pairs with "&". The generated request +// builder then URL-encodes this whole string as a single query value, so the +// per-pair encoding survives the round trip (matching the Python/JS SDKs). +// Pairs are sorted for deterministic output. +func encodeMetadataQuery(md map[string]string) string { + if len(md) == 0 { + return "" + } + parts := make([]string, 0, len(md)) + for k, v := range md { + parts = append(parts, url.QueryEscape(k)+"="+url.QueryEscape(v)) + } + sort.Strings(parts) + return strings.Join(parts, "&") +} diff --git a/sandbox.go b/sandbox.go index ba8c929..2225df3 100644 --- a/sandbox.go +++ b/sandbox.go @@ -83,100 +83,45 @@ type ConnectOptions struct { // Create provisions a new sandbox and returns a *Sandbox wired up with all // sub-clients. The caller owns the returned sandbox and should call Kill // (directly or via defer) to release it. +// +// Deprecated: prefer NewClient(cfg).Create, which reuses one control-plane +// REST client across calls. This helper builds a throwaway Client from +// opts.Config on every call. func Create(ctx context.Context, opts CreateOptions) (*Sandbox, error) { - cfg := opts.Config.resolve() - hc := cfg.httpClient() - auth := transport.Auth{APIKey: cfg.APIKey, AccessToken: cfg.AccessToken, Headers: cfg.Headers} - apiCli, err := transport.NewAPIClient(cfg.APIURL, hc, auth) - if err != nil { - return nil, newSandboxError("init api client", err) - } - - body := buildNewSandbox(opts) - resp, err := apiCli.PostSandboxes(ctx, body) + c, err := NewClient(opts.Config) if err != nil { - return nil, mapHTTPOrCtx(err) - } - defer resp.Body.Close() - if err := mapHTTPErr(resp, ""); err != nil { return nil, err } - parsed, err := apiclient.ParsePostSandboxesResponse(resp) - if err != nil { - return nil, newSandboxError("parse create response", err) - } - created := parsed.JSON201 - if created == nil { - return nil, newSandboxError("empty create response body", nil) - } - return newSandbox(cfg, apiCli, hc, created) + return c.Create(ctx, opts) } // Connect attaches to an existing sandbox (running or paused). If paused, the // server resumes it; the call sets the sandbox timeout to opts.Timeout // (default 5 minutes). +// +// Deprecated: prefer NewClient(cfg).Connect (see Create). func Connect(ctx context.Context, sandboxID string, opts ConnectOptions) (*Sandbox, error) { - cfg := opts.Config.resolve() - hc := cfg.httpClient() - auth := transport.Auth{APIKey: cfg.APIKey, AccessToken: cfg.AccessToken, Headers: cfg.Headers} - apiCli, err := transport.NewAPIClient(cfg.APIURL, hc, auth) + c, err := NewClient(opts.Config) if err != nil { - return nil, newSandboxError("init api client", err) - } - timeout := opts.Timeout - if timeout <= 0 { - timeout = DefaultSandboxTimeout - } - body := apiclient.ConnectSandbox{Timeout: int32(timeout / time.Second)} - resp, err := apiCli.PostSandboxesSandboxIDConnect(ctx, sandboxID, body) - if err != nil { - return nil, mapHTTPOrCtx(err) - } - defer resp.Body.Close() - if err := mapHTTPErr(resp, sandboxID); err != nil { return nil, err } - parsed, err := apiclient.ParsePostSandboxesSandboxIDConnectResponse(resp) - if err != nil { - return nil, newSandboxError("parse connect response", err) - } - var connected *apiclient.Sandbox - if parsed.JSON201 != nil { - connected = parsed.JSON201 - } else if parsed.JSON200 != nil { - connected = parsed.JSON200 - } - if connected == nil { - return nil, newSandboxError("empty connect response body", nil) - } - return newSandbox(cfg, apiCli, hc, connected) + return c.Connect(ctx, sandboxID, opts) } // Kill terminates a sandbox by ID. Returns false (nil error) if the sandbox // was already gone. +// +// Deprecated: prefer NewClient(cfg).Kill (see Create). func Kill(ctx context.Context, sandboxID string, opts ConnectOptions) (bool, error) { - cfg := opts.Config.resolve() - hc := cfg.httpClient() - if cfg.Debug { + // Preserve the historical behaviour: in Debug mode never touch the network. + if opts.Config.resolve().Debug { return true, nil } - auth := transport.Auth{APIKey: cfg.APIKey, AccessToken: cfg.AccessToken, Headers: cfg.Headers} - apiCli, err := transport.NewAPIClient(cfg.APIURL, hc, auth) + c, err := NewClient(opts.Config) if err != nil { - return false, newSandboxError("init api client", err) - } - resp, err := apiCli.DeleteSandboxesSandboxID(ctx, sandboxID) - if err != nil { - return false, mapHTTPOrCtx(err) - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNotFound { - return false, nil - } - if err := mapHTTPErr(resp, sandboxID); err != nil { return false, err } - return true, nil + return c.Kill(ctx, sandboxID) } // Kill terminates this sandbox. @@ -331,21 +276,23 @@ func (s *Sandbox) DownloadURL(path string, opts SignatureOptions) (string, error return s.buildFileURL(path, SignatureRead, opts, false) } -// newSandbox builds the per-sandbox sub-clients from a freshly parsed -// REST response. -func newSandbox(cfg Config, apiCli *apiclient.Client, hc *http.Client, created *apiclient.Sandbox) (*Sandbox, error) { +// newSandbox builds the per-sandbox sub-clients from a freshly parsed REST +// response, reusing the Client's shared HTTP and control-plane REST clients. +// The envd Connect-RPC/HTTP clients are still built per sandbox (their base +// URL and access token are sandbox-specific) but share the Client's *http.Client. +func (c *Client) newSandbox(created *apiclient.Sandbox) (*Sandbox, error) { sbx := &Sandbox{ ID: created.SandboxID, EnvdVersion: created.EnvdVersion, - cfg: cfg, - apiCli: apiCli, - httpCli: hc, + cfg: c.cfg, + apiCli: c.apiCli, + httpCli: c.httpCli, } if created.Domain != nil { sbx.Domain = *created.Domain } if sbx.Domain == "" { - sbx.Domain = cfg.Domain + sbx.Domain = c.cfg.Domain } if created.EnvdAccessToken != nil { sbx.EnvdAccessToken = *created.EnvdAccessToken @@ -354,13 +301,13 @@ func newSandbox(cfg Config, apiCli *apiclient.Client, hc *http.Client, created * sbx.TrafficAccessToken = *created.TrafficAccessToken } - envdBase := cfg.sandboxURL(sbx.ID, sbx.Domain) + envdBase := c.cfg.sandboxURL(sbx.ID, sbx.Domain) envdAuth := transport.EnvdAuth{ Token: sbx.EnvdAccessToken, User: defaultUser, - Headers: mergeHeaders(cfg.Headers, cfg.ExtraSandboxHeaders), + Headers: mergeHeaders(c.cfg.Headers, c.cfg.ExtraSandboxHeaders), } - envd, err := transport.NewEnvdClients(envdBase, hc, envdAuth) + envd, err := transport.NewEnvdClients(envdBase, c.httpCli, envdAuth) if err != nil { return nil, newSandboxError("init envd client", err) } diff --git a/types.go b/types.go index 09c712b..8711d59 100644 --- a/types.go +++ b/types.go @@ -169,4 +169,34 @@ func sandboxInfoFromAPI(d *apiclient.SandboxDetail) *SandboxInfo { return info } +// sandboxInfoFromListed converts a ListedSandbox (returned by the list +// endpoint) into the public type. ListedSandbox carries fewer fields than +// SandboxDetail, so Domain, EnvdAccessToken, AllowInternetAccess, Network and +// Lifecycle are left zero — call GetInfo for the full record. +func sandboxInfoFromListed(d *apiclient.ListedSandbox) *SandboxInfo { + info := &SandboxInfo{ + SandboxID: d.SandboxID, + TemplateID: d.TemplateID, + State: SandboxState(d.State), + CPUCount: int32(d.CpuCount), + MemoryMB: int32(d.MemoryMB), + DiskSizeMB: int32(d.DiskSizeMB), + StartedAt: d.StartedAt, + EndAt: d.EndAt, + EnvdVersion: d.EnvdVersion, + } + if d.Alias != nil { + info.Alias = *d.Alias + } + if d.Metadata != nil { + info.Metadata = map[string]string(*d.Metadata) + } + if d.VolumeMounts != nil { + for _, m := range *d.VolumeMounts { + info.VolumeMounts = append(info.VolumeMounts, VolumeMount{Name: m.Name, Path: m.Path}) + } + } + return info +} + func urlEscape(s string) string { return url.QueryEscape(s) }