diff --git a/docs/e2b-compat.md b/docs/e2b-compat.md index b878b53..3685dc1 100644 --- a/docs/e2b-compat.md +++ b/docs/e2b-compat.md @@ -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. | diff --git a/docs/envd-proxy.md b/docs/envd-proxy.md index 71cd762..91902fd 100644 --- a/docs/envd-proxy.md +++ b/docs/envd-proxy.md @@ -159,7 +159,7 @@ portsmoke -addr 127.0.0.1:7990 -token -template \ -listener ./guestserver -hold 300s # prints: SANDBOX go run -tags envdproxysmoke ./test/envdproxysmoke \ - -node -sandbox -token -port 49983 + -node -sandbox -token -node-token -port 49983 ``` `-guest envd` swaps the assertions for the real daemon (health, a ConnectRPC diff --git a/docs/performance.md b/docs/performance.md index 01d6fc2..8fe579a 100644 --- a/docs/performance.md +++ b/docs/performance.md @@ -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 -sandbox -token -port 49983 + -node -sandbox -token -node-token -port 49983 # envd in a real guest (see e2b-compat.md, Proving envd on hardware) K= TEMPLATE=ghcr.io/cocoonstack/sandbox/e2b-rt:24.04 bash scripts/envd-e2e.sh diff --git a/helm/templates/envdproxy-deployment.yaml b/helm/templates/envdproxy-deployment.yaml index def3fc9..1e9062f 100644 --- a/helm/templates/envdproxy-deployment.yaml +++ b/helm/templates/envdproxy-deployment.yaml @@ -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: @@ -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 @@ -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 @@ -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. diff --git a/pkg/e2bcompat/templates.go b/pkg/e2bcompat/templates.go index 2b2c1f9..7fdf30d 100644 --- a/pkg/e2bcompat/templates.go +++ b/pkg/e2bcompat/templates.go @@ -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 { diff --git a/pkg/e2bcompat/templates_test.go b/pkg/e2bcompat/templates_test.go index d4f5332..80eef17 100644 --- a/pkg/e2bcompat/templates_test.go +++ b/pkg/e2bcompat/templates_test.go @@ -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)) diff --git a/pkg/envdproxy/resolver.go b/pkg/envdproxy/resolver.go index e3d8969..e4b0093 100644 --- a/pkg/envdproxy/resolver.go +++ b/pkg/envdproxy/resolver.go @@ -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 diff --git a/test/envdproxysmoke/main.go b/test/envdproxysmoke/main.go index 2e41170..36f3286 100644 --- a/test/envdproxysmoke/main.go +++ b/test/envdproxysmoke/main.go @@ -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() @@ -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 }