Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/e2b-compat.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ and its first `runCode` fails with `502`.

| e2b endpoint | Maps to | Notes |
|---|---|---|
| `POST /sandboxes`, `POST /v2/sandboxes` | `store.Claim` | `templateID` → pool template, through `--e2b-template-alias-file` when it names an alias; `timeout` → the claim's TTL (`--e2b-default-timeout` when omitted); `allow_internet_access: true` → `egress` lane, anything else the hardened `none` lane; `metadata` → the claim's metadata; `autoPause: true` → the claim is paused (hibernated and archived) at lease end instead of destroyed, and connect resumes it. Both ride on the same claim call and need sandboxd built from 3fd7af2 or later. `envVars` becomes `envd`'s default environment through the `/init` that follows the claim (see the access token below); a failed `/init` releases the claim and answers `500`. `201` on success, `400` for an option this backend cannot honor (see below), `404` at once for a `templateID` spelled as a full `e2b/` key (a built template is named bare and scoped by the key's namespace), `503` when the pool is drained (retryable), `404` when no warm capacity answers and the `templateID` names nothing known — no alias, no [built template](#built-templates) of the namespace, no advertised pool image. The check runs only on that failure branch. SDK 2.51 creates through `/v2`. |
| `POST /sandboxes`, `POST /v2/sandboxes` | `store.Claim` | `templateID` → pool template, through `--e2b-template-alias-file` when it names an alias, or a [built template](#built-templates) by name, `name:tag` for one of its live tags or `name:default` for the current build; `timeout` → the claim's TTL (`--e2b-default-timeout` when omitted); `allow_internet_access: true` → `egress` lane, anything else the hardened `none` lane; `metadata` → the claim's metadata; `autoPause: true` → the claim is paused (hibernated and archived) at lease end instead of destroyed, and connect resumes it. Both ride on the same claim call and need sandboxd built from 3fd7af2 or later. `envVars` becomes `envd`'s default environment through the `/init` that follows the claim (see the access token below); a failed `/init` releases the claim and answers `500`. `201` on success, `400` for an option this backend cannot honor (see below), `404` at once for a `templateID` spelled as a full `e2b/` key (a built template is named bare and scoped by the key's namespace), `503` when the pool is drained (retryable), `404` when no warm capacity answers and the `templateID` names nothing known — no alias, no [built template](#built-templates) of the namespace, no advertised pool image. The check runs only on that failure branch. SDK 2.51 creates through `/v2`. |
| `GET /sandboxes`, `GET /v2/sandboxes` | `store.List` | Live sandboxes in the key's namespace. The `state` (`running`, `paused`), `template` and `startedAfter` (at the second precision `startedAt` carries) filters are honored, and so is `metadata`: `key=value` pairs joined by `&`, each key and value URL-encoded as the JS and Python SDKs send `query.metadata`; every pair must match; a pair without `=`, an empty key or a repeated key is `400`. Each item carries its `metadata`, `cpuCount` and `memoryMB`. `/v2` pages as the spec says: `limit` (1 to 100, default 100), `order` by start time (`desc`, newest first, by default, or `asc`) and `nextToken`, the opaque cursor the previous page returned in `X-Next-Token`. The cursor names the last sandbox served, so a sandbox created or released between pages neither repeats nor skips the rest. An out-of-range `limit`, an unknown `order` or a malformed `nextToken` is `400`. The legacy `GET /sandboxes` takes no page parameters and returns every match. |
| `GET /sandboxes/{id}` | `store.GetByClaimID`, then `store.Read` | Resolves the owning node and materializes only that entry; `state` and `endAt` come from the node's own record, so they reflect a pause, resume or renew at once; `404` when no live sandbox carries the id. |
| `DELETE /sandboxes/{id}` | `store.Release` | Releases the node-local claim id, never by Kubernetes name. `204`, also when the owning node already reaped it: release is idempotent. `404` when the read view no longer lists the id. |
Expand Down
2 changes: 1 addition & 1 deletion docs/envd-proxy.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,7 +159,7 @@ portsmoke -addr 127.0.0.1:7990 -token <node-token> -template <ref> \
-listener ./guestserver -hold 300s # prints: SANDBOX <id> <token> <owner> <port>

go run -tags envdproxysmoke ./test/envdproxysmoke \
-node <owner> -sandbox <id> -token <token> -port 49983
-node <owner> -sandbox <id> -token <token> -node-token <node api token> -port 49983
```

`-guest envd` swaps the assertions for the real daemon (health, a ConnectRPC
Expand Down
2 changes: 1 addition & 1 deletion docs/performance.md
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,7 @@ go test -run '^$' -bench . ./pkg/scale ./pkg/e2bcompat

# envd-proxy against a live sandbox (see envd-proxy.md for the node half)
go run -tags envdproxysmoke ./test/envdproxysmoke \
-node <owner> -sandbox <id> -token <token> -port 49983
-node <owner> -sandbox <id> -token <token> -node-token <node api token> -port 49983

# envd in a real guest (see e2b-compat.md, Proving envd on hardware)
K=<kit> TEMPLATE=ghcr.io/cocoonstack/sandbox/e2b-rt:24.04 bash scripts/envd-e2e.sh
Expand Down
9 changes: 2 additions & 7 deletions helm/templates/envdproxy-deployment.yaml
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
{{- if .Values.apiserver.e2b.enabled }}
{{- $tlsSecret := .Values.envdProxy.tlsSecretName -}}
{{- $tokenSecret := required "apiserver.sandboxdToken.secretName is required when apiserver.e2b.enabled is true: the envd proxy reads claim tokens with it" .Values.apiserver.sandboxdToken.secretName -}}
apiVersion: apps/v1
kind: Deployment
metadata:
Expand Down Expand Up @@ -39,9 +40,7 @@ spec:
- --domain={{ . }}
{{- end }}
- --e2b-envd-secret-file=/etc/sandbox-envd-proxy/e2b-envd-secret/{{ .Values.apiserver.e2b.envdSecret.key }}
{{- if .Values.apiserver.sandboxdToken.secretName }}
- --sandboxd-token-file=/etc/sandbox-envd-proxy/sandboxd-token/{{ .Values.apiserver.sandboxdToken.key }}
{{- end }}
{{- if $tlsSecret }}
- --tls-cert-file=/etc/sandbox-envd-proxy/serving-certs/tls.crt
- --tls-private-key-file=/etc/sandbox-envd-proxy/serving-certs/tls.key
Expand Down Expand Up @@ -70,11 +69,9 @@ spec:
- name: e2b-envd-secret
mountPath: /etc/sandbox-envd-proxy/e2b-envd-secret
readOnly: true
{{- if .Values.apiserver.sandboxdToken.secretName }}
- name: sandboxd-token
mountPath: /etc/sandbox-envd-proxy/sandboxd-token
readOnly: true
{{- end }}
{{- if $tlsSecret }}
- name: serving-certs
mountPath: /etc/sandbox-envd-proxy/serving-certs
Expand All @@ -84,11 +81,9 @@ spec:
- name: e2b-envd-secret
secret:
secretName: {{ include "sandbox-operator.e2b.envdSecretName" . }}
{{- with .Values.apiserver.sandboxdToken.secretName }}
- name: sandboxd-token
secret:
secretName: {{ . }}
{{- end }}
secretName: {{ $tokenSecret }}
{{- if $tlsSecret }}
# A wildcard certificate for *.{domain}: the SDK addresses every sandbox
# by its own derived host.
Expand Down
13 changes: 9 additions & 4 deletions pkg/e2bcompat/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -288,14 +288,19 @@ func (s *Server) builtTemplate(w http.ResponseWriter, r *http.Request, name stri
return nil, false
}

// resolveTemplate reports name's built template in the caller's namespace, and whether an alias or an advertised pool image names it.
func (s *Server) resolveTemplate(r *http.Request, name string) (*builtTemplate, bool, error) {
_, aliased := s.aliases[name]
// resolveTemplate reports ref's built template in the caller's namespace (ref is a name, or name:tag for one of its live tags), and whether an alias or an advertised pool image names ref.
func (s *Server) resolveTemplate(r *http.Request, ref string) (*builtTemplate, bool, error) {
_, aliased := s.aliases[ref]
nodes, err := s.inventories(r.Context())
if err != nil {
return nil, aliased, err
}
return builtTemplates(nodes, s.templateScope(r), name)[name], aliased || advertisedIn(nodes, name), nil
name, tag, _ := strings.Cut(ref, ":")
b := builtTemplates(nodes, s.templateScope(r), name)[name]
if b != nil && tag != "" && tag != defaultTag && b.liveOnly(b.current().labels)[tag] == "" {
b = nil
}
return b, aliased || advertisedIn(nodes, ref), nil
}

func (s *Server) templateScope(r *http.Request) string {
Expand Down
19 changes: 19 additions & 0 deletions pkg/e2bcompat/templates_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,25 @@ func TestACreateNamingABuiltTemplateClaimsItsKey(t *testing.T) {
assert.Equal(t, 1, store.claimCalls, "a drained alias pool never falls through to a built template of the same name")
}

func TestACreateNamingATagOfABuiltTemplateClaimsItsKey(t *testing.T) {
created := &metav1.Time{Time: time.Date(2026, 9, 28, 1, 2, 3, 0, time.UTC)}
for ref, want := range map[string]int{"app": http.StatusCreated, "app:v1": http.StatusCreated, "app:default": http.StatusCreated, "app:v9": http.StatusNotFound, "app:old": http.StatusNotFound} {
inv := scale.NewStaticInventorySource()
inv.Put(&scale.NodeInventory{Node: "node-a", Templates: []scale.PromotedTemplate{{
Template: "e2b/sandboxes/app", Net: "none", Size: "small", ContentDigest: "sha256:aa", CreatedAt: created,
Labels: map[string]string{"v1": "sha256:aa", "old": "sha256:gone"},
}}})
store := &fakeStore{firstClaimErr: scale.ErrNoWarmCapacity, assign: scale.Assignment{SandboxName: "sb_1", Node: "node-a"}, fleet: inv}
h := newTestServer(t, store, func(o *Options) { o.Inventory = inv })

w := do(t, h, http.MethodPost, "/sandboxes", `{"templateID":"`+ref+`"}`, testKey)
assert.Equal(t, want, w.Code, "%s: %s", ref, w.Body.String())
if want == http.StatusCreated {
assert.Equal(t, "e2b/sandboxes/app", store.claimPool.Template, ref)
}
}
}

func TestABuiltTemplateCreateKeepsTheTemplateDefaultsAndLayersTheRequestEnvs(t *testing.T) {
store := &fakeStore{firstClaimErr: scale.ErrNoWarmCapacity, assign: scale.Assignment{SandboxName: "sb_1", Node: "node-a", Token: "tok"}}
h := newTestServer(t, store, withTemplateFleet(store))
Expand Down
4 changes: 2 additions & 2 deletions pkg/envdproxy/resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,8 +119,8 @@ func (s *storeResolver) find(ctx context.Context, sandboxID string, admit admiss
return s.readOwner(ctx, sandboxID, sb.Status.NodeName, sb.Annotations[scale.ClaimIDAnnotation], admit)
}

// probe asks every node for claimID, which its inventory does not list yet, and keeps a hit past its
// next publish; a refused admit reads as not found, so an unpublished id stays unprovable.
// probe asks every node for claimID, which its inventory does not list yet, keeps a hit past its
// next publish and admits the caller like a published one.
func (s *storeResolver) probe(ctx context.Context, sandboxID, claimID string, admit admission) (Owner, error) {
if !s.probeLimit.Allow() {
return Owner{}, ErrSandboxNotFound
Expand Down
11 changes: 6 additions & 5 deletions test/envdproxysmoke/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,23 +123,24 @@ func main() {
node := flag.String("node", "", "owning node's sandboxd address")
sandboxID := flag.String("sandbox", "", "node-local claim id")
token := flag.String("token", "", "per-sandbox access token")
nodeToken := flag.String("node-token", "", "node api token; a signed file URL relays with it")
port := flag.Uint("port", 49983, "guest port the listener is on")
guestHTTP2 := flag.Bool("guest-http2", false, "forward to the guest over cleartext HTTP/2")
mode := flag.String("guest", "echo", "what listens in the guest: echo (guestserver) or envd")
flag.Parse()

if *node == "" || *sandboxID == "" || *token == "" {
fmt.Fprintln(os.Stderr, "envdproxysmoke: -node, -sandbox and -token are required")
if *node == "" || *sandboxID == "" || *token == "" || *nodeToken == "" {
fmt.Fprintln(os.Stderr, "envdproxysmoke: -node, -sandbox, -token and -node-token are required")
os.Exit(1)
}
if err := run(*node, *sandboxID, *token, uint16(*port), *guestHTTP2, *mode); err != nil {
if err := run(*node, *sandboxID, *token, *nodeToken, uint16(*port), *guestHTTP2, *mode); err != nil {
fmt.Fprintln(os.Stderr, "envdproxysmoke:", err)
os.Exit(1)
}
fmt.Println("ENVDPROXYSMOKE PASS")
}

func run(node, sandboxID, token string, port uint16, guestHTTP2 bool, mode string) error {
func run(node, sandboxID, token, nodeToken string, port uint16, guestHTTP2 bool, mode string) error {
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()

Expand All @@ -148,7 +149,7 @@ func run(node, sandboxID, token string, port uint16, guestHTTP2 bool, mode strin
publicID := e2bcompat.PublicID(sandboxID)
access := e2bcompat.AccessToken([]byte("envdproxysmoke"), token)
srv, err := envdproxy.NewServer(staticResolver{claimID: sandboxID, address: node, publicID: publicID, token: token, access: access},
envdproxy.Options{Domain: domain, GuestHTTP2: guestHTTP2})
envdproxy.Options{Domain: domain, GuestHTTP2: guestHTTP2, NodeToken: nodeToken})
if err != nil {
return err
}
Expand Down
Loading