E2B-Compatible Template Catalog and OCI Image Builds
此内容尚不支持你的语言。
Source:
docs/enhancements/0019-e2b-template-api/README.md· Status:implemented· Area:gateway· Milestone:v0.4.0| Field | Value | | :--- | :--- | | CEP Number |0019| | Status |implementable(alpha) | | Owning Area |gateway| | Participating Areas |controller,runtime,sdk| | Authors | @cubepod-maintainers | | Created | 2026-11-12 | | Last Updated | 2026-11-13 |
Release Signoff Checklist
Section titled “Release Signoff Checklist”- Enhancement scope, Goals, and Non-Goals are agreed upon (
provisional->implementable) - API / CRD (
cubepod.io/v1alpha1PodTemplate) and E2B template OpenAPI changes (api/openapi/cubepod-v1.yaml) are specified - Dual-runtime behavior (Kubernetes Kata + CSI Block AND Local Docker Loopback) is defined
- Security boundary (rootless BuildKit, registry credentials stay out of image layers and out of the sandbox, UID 1000 default,
/workspaceuntouched) is reviewed - Test plan includes unit tests (
go test) and a pinnede2bSDK conformance test - Production Readiness Review (PRR) section is complete
Summary
Section titled “Summary”CubePod can already start a sandbox from an image name (POST /sandboxes with templateID), but it has no template catalog that the official E2B SDK can list, inspect, or build. GET /templates returns three hardcoded cards (id, name, image, description) wrapped in {"templates":[...]}. The official e2b SDK expects a template resource with templateID, builds, aliases, tags, and a Dockerfile-shaped build.
This CEP adds that resource on top of the existing PodTemplate CRD. A template resolves to an OCI image plus CPU, memory, and rootfs size. Registering an image that already exists in a registry is the Alpha path and does not run a builder. The Beta path accepts the E2B build request (fromImage or fromTemplate, plus steps) and builds an OCI image with rootless BuildKit. The image is the read-only lower layer. Each sandbox still gets its own OverlayFS upper on rootfs-pvc, and /workspace stays on workspace-pvc. podd-init and podd stay out of the image and are injected at Pod start, as they are today.
Motivation
Section titled “Motivation”CEP-0018 aligns sandbox create, commands, and files with the E2B wire protocol. Template management is the remaining gap that blocks Template.build, Template.list, and Sandbox.create("my-alias") from the official SDK.
What exists today:
GET /templates(pkg/gateway/server.go,handleListTemplates) encodes thedefaultTemplatesslice. It does not read Kubernetes.POST /sandboxescopiestemplateIDontoPodBox.spec.templateRef. The reconciler uses a registeredPodTemplatewhen one is in memory. OtherwisedefaultTemplatetreats the string as an OCI image reference and fills in Kata runtime class,1/2CPU,2Gi/4Gimemory,10Girootfs, and20Giworkspace (pkg/controller/reconciler.go).- The aliases
base,code-interpreter,code-interpreter-v1, andpythonare rewritten tocubeplex-ubuntu24-desktopbefore that lookup. PodTemplate(api/v1alpha1/types.go, CRDdeploy/helm/cubepod/crds/cubepod.io_podtemplates.yaml) storesimage, resources, storage, and egress. It has no aliases, build status, start command, or public flag. The CRD has nostatussubresource.- Python
list_templates()callsGET /templatesand accepts either a JSON array or the wrapped object. The Go client andcubepodCLI have no template command.
Workarounds that are not enough:
- Passing an image name as
templateIDworks for operators who already pushed an image. The official SDK never does this. It creates a template, uploads a context tar, starts a build, and polls status. - Hand-writing
PodTemplatemanifests is a cluster-admin path. It is invisible toe2band has no build logs.
- Alpha: with feature gate
E2BTemplateAPIenabled, the official SDK can list templates, get one by id or alias, delete it, and mark it public. It can register a template whose only base is an existing OCI image (fromImage, emptysteps) and thenSandbox.createthat alias. Create responses use that template’scpuCount,memoryMB, anddiskSizeMBinstead of the hardcoded2/2048/10240. - Alpha: the three built-in images stay resolvable.
base,code-interpreter,code-interpreter-v1, andpythonstill startcubeplex-ubuntu24-desktop. - Beta:
Template.buildagainst a pinnede2bSDK version completes for a Dockerfile subset (FROM,RUN,COPY,WORKDIR,USER,ENV, start command, ready command). The build pushes an OCI image. Sandboxes created afterwards use that image. A failed rebuild leaves the previous ready image in place. - Both phases keep the dual-volume rule. A template build or a template delete never formats, resizes, or deletes an existing sandbox’s
rootfs-pvcorworkspace-pvc. - Both phases behave in Production Kubernetes (Kata + CSI raw block) and in local Docker + loopback ext4.
Non-Goals
Section titled “Non-Goals”- Porting the E2B template manager (Firecracker boot, systemd and
envdlayers, memory snapshot,userfaultfdpage prefetch). That pipeline produces a paused VM snapshot. CubePod boots an OCI image through containerd and Kata, thenpodd-initpivots onto a per-sandbox ext4 upper. - Baking
podd-init,podd, orenvdinto the template image. - Turning a live sandbox into a template (
docker commit, CSI snapshot of the upper). E2B treats that as a sandbox snapshot, which CEP-0018 already distinguishes from a template. A later CEP can cover it. - Multi-tenant
teamID, cross-project public templates, and E2B tag-group pagination. The deprecatedteamIDfield is accepted and ignored.publicis stored and returned. - Dockerfile instructions E2B itself skips (
EXPOSE,VOLUME,ARGbuild-args beyond the cache-bust this CEP inserts). An incoming step whosetypeisARGreturns 400. The onlyARGline in the rendered Dockerfile is the cache-bust this CEP inserts. - Multi-architecture images. Builds target the node’s amd64 userspace, which is the Kata guest architecture this cluster runs.
- Replacing
cubepodSDK template helpers. They gain the new calls. The officiale2bSDK remains the conformance client. - A highly available builder. Beta runs a single builder replica. Shared object storage for context tars is a Stable item.
- mTLS on the BuildKit port. Beta allows only the controller to connect, through a NetworkPolicy. The Service is not published outside the cluster.
Proposal
Section titled “Proposal”Speak the E2B template HTTP API at the gateway. Persist the template as a PodTemplate. Produce an OCI image, either by recording a registry reference (Alpha) or by building one with BuildKit (Beta). Resolve templateID on sandbox create through that record.
The build input is the same recipe E2B already documents as a Dockerfile subset (Defining a template, Base image). The artifact is the OCI image Kubernetes, Kata, ignite, and Docker Sandboxes already run. BuildKit is the builder those communities use for rootless, cacheable image builds. E2B’s per-step cache key (base hash + step type + args + filesHash, see e2b-dev/infra packages/orchestrator/internal/template/build/phases/steps/hash.go) maps onto BuildKit’s instruction cache plus a content-addressed context tar. force: true maps to BuildKit --no-cache.
kubernetes-sigs/agent-sandbox uses the same split we already have: a template is a reusable pod recipe that points at an image (SandboxTemplate), and a warm pool (SandboxWarmPool) stamps pods from it. CubePod already has PodTemplate and PodWarmPool. This CEP extends PodTemplate instead of adding a second template CRD.
E2B’s startCmd is captured inside a memory snapshot, so the process is already running when a sandbox resumes. CubePod does not snapshot guest memory on template build. podd starts startCmd once after pivot_root, and the ready command is a localhost probe. A long-running server comes up on every boot. A one-shot command runs again on every boot. That is the same outcome operators get from a container CMD, and it is the outcome kubernetes-sigs/agent-sandbox and Docker Sandboxes provide. It is not a resumed Firecracker snapshot.
Prior Art
Section titled “Prior Art”| Source | What it does | What this CEP takes |
|---|---|---|
E2B API, docs as of 2026-11-12 (docs.e2b.dev OpenAPI) |
Catalog, alias lookup, create (POST /v3/templates returns 202 with templateID and buildID), GET /templates/{templateID}/files/{hash} returns {present,url,headers}, POST /v2/templates/{templateID}/builds/{buildID} with TemplateBuildStartV2, poll status, read logs, tags |
The wire protocol the official SDK speaks. Status enum waiting, building, uploaded, ready, error. |
E2B template manager (e2b-dev/infra, orchestrator build role) |
Pull an OCI image, inject a systemd layer and envd, boot Firecracker, run each step in the guest, snapshot memory and disk, upload the snapshot |
The step list and the filesHash context tar. Not the snapshot artifact. |
E2B SDK note on setEnvs / set_envs (E2B commit 1ebc6df) |
Variables set on the template exist only while the build runs | Build-time env is applied to RUN steps and is not written to the image Config.Env. Sandbox env still comes from POST /sandboxes envVars. |
BuildKit (moby/buildkit) |
Rootless daemon, registry cache, Dockerfile frontend, context streamed by the client | The Beta builder. A failed instruction fails the build. Cache scope is the cluster registry. |
kubernetes-sigs/agent-sandbox SandboxTemplate |
Image, runtimeClassName, resources, and a warm pool are the template. Users do not get a second build API. |
Keep one template CRD. Do not invent a parallel SandboxTemplate. |
| Docker Sandboxes | Custom templates are docker build of an OCI image and a registry push (or docker image save plus load) |
Local mode uses the Docker daemon the local runtime already uses. Production does not mount docker.sock into the builder or into sandboxes. |
| Kata / containerd | The Pod’s container image is an OCI image unpacked by the snapshotter. The guest rootfs is that snapshot plus whatever the agent mounts. | The built image is PodTemplate.spec.image. podd-init keeps mounting the block upper on top. |
User Stories
Section titled “User Stories”Story 1: Register an image the cluster can already pull
Section titled “Story 1: Register an image the cluster can already pull”As a CubePlex operator, I want POST /v2/templates with alias python-tools and a follow-up start-build of fromImage: python:3.13 and no steps, so that Sandbox.create("python-tools") from the official SDK pulls python:3.13 and sizes the MicroVM from the template’s CPU and memory.
Story 2: Build a template from a recipe
Section titled “Story 2: Build a template from a recipe”As an agent author using e2b.Template, I want Template.build of from_python_image("3.13").copy("requirements.txt", "/home/user/").run_cmd("pip install -r /home/user/requirements.txt") to finish against E2B_DOMAIN pointing at cubepod-gateway, so the next sandbox has those packages in the lower image and an empty writable upper.
Story 3: Rebuild without touching workspaces
Section titled “Story 3: Rebuild without touching workspaces”As a cluster operator, I want a rebuilt template to affect only sandboxes created after the build reaches ready. Existing rootfs-pvc uppers and every workspace-pvc stay as they are. Warm-pool standbys keep the image they were stamped with until the pool is scaled or recreated.
Story 4: Local development without a registry
Section titled “Story 4: Local development without a registry”As a developer running docker-compose, I want the same API to docker build into the local daemon when no registry is configured, so pkg/runtime starts a container from that local tag.
Notes / Constraints / Caveats
Section titled “Notes / Constraints / Caveats”- Kubernetes object names are DNS-1123. An alias such as
ubuntu:24.04ormy-template:v1cannot bemetadata.name. The object name is a slug (ubuntu-24-04). The E2B name and tags live inspec.names. Lookup is bystatus.templateID,metadata.name, or any entry inspec.names. GET /templatesin the E2B API returns a JSON array, not an object. Official clients will not read{"templates":[...]}. The feature gate switches this response. With the gate off, the handler stays the current stub so existing gateway tests keep passing.- E2B
diskSizeMBon the template resource is an output. The input that sizes the disk isminFreeDiskMbonPOST /v3/templates(Build). CubePod does not grow an ext4 image inside BuildKit. It setsspec.storage.rootfsSizeto at leastminFreeDiskMb(MiB converted to a Kubernetes quantity, minimum10Gi) on the per-sandbox upper block device.workspaceSizestays on its own field and defaults to20Gi. COPYsteps carry afilesHashof 64 lowercase hex characters. That hash is computed by the official SDK over the textCOPY <src> <dest>plus each file’s relative path, mode, size, and bytes (e2bJS SDKcalculateFilesHash). It is not the SHA-256 of the HTTP body. The default upload is a gzip-compressed tar (GZIP = truein that SDK). The gateway stores the decompressed tar under the path hash and does not recompute the SDK’s hash.- E2B’s
GET /templates/{templateID}/files/{hash}returns 201{present, url, headers}. Whenpresentis true the SDK skips the upload. The URL we return is an internal upload endpoint on the gateway, not a third-party object store. - Two registry credentials stay separate.
fromImageRegistry(registry,aws, orgcp) is only the pull credential for the base image named in the build request. The controller writes it to Secretcubepod-template-src-<buildID>, passes that Secret only to the BuildKit session, and deletes it when the build reachesreadyorerror. The registry that stores<registry>/cubepod-templates/...is different. Helm setstemplateBuild.registry.pushSecretandtemplateBuild.registry.pullSecret(they may name the same Secret). The controller uses the push Secret for the BuildKit push and never creates or deletes it from the client request. Every sandbox Pod and the build probe Pod setsimagePullSecretsto the pull Secret. A node that has not cached the image can still pull it. Neither Secret is mounted as a file in the sandbox, passed as imageENV, or written into build logs. Sandbox secrets remaincpodref_placeholders (CEP-0005). - A template build must not place files at
/workspace.COPY/ADDdestinations andWORKDIRvalues of/workspaceor a path under it return 400 before BuildKit starts. The CEP does not name a replacement directory. Any other path in the image is left as the recipe wrote it. At runtimeworkspace-pvcis mounted on/workspace, so anything aRUNstep still wrote there is hidden for the life of the sandbox. The workspace volume is not seeded from the image. - Template
ENVsteps are build-time only, matching the current E2B SDK contract. They are prefixed onto each followingRUN. They are not committed as image environment. A user who needs runtime env setsenvVarson sandbox create. USERis stored on the template. WhenrunAsUserandrunAsUsernameare both unset, agent commands and uploaded files staycubeplex1000:1000. When either is set,poddruns agent commands and writes uploads as that user. Numeric0is root.podddoes the ownership change. The image build does not require a follow-upchownin the sandbox shell.- The builder is one long-lived rootless BuildKit Deployment in
cubepod-system, scheduled as an ordinary runc Pod. It is not a per-build Job and it is not a Kata MicroVM. See Where the builder runs. - Alpha
fromImagewith emptystepsmarks the templatereadywithout pulling the image. containerd pulls it on the first sandbox create. A missing or unauthorized image fails that Pod and does not changebuildStatus. - The official JS SDK (
waitForBuildFinishinpackages/js-sdk/src/template/buildApi.ts) keeps polling only while build status iswaitingorbuilding. The CR may storeuploadedafter the push. The status and list routes report that phase asbuilding. Returninguploadedon those routes makesTemplate.buildthrowUnknown build error occurred. - Build-time
ENVvalues can still leak through shell history inside aRUNlayer if the Dockerfile author echoes them. The gateway rejects a step whose args contain a registry password or acpodref_raw secret. It does not try to scan arbitraryRUNscripts for other secrets.
Risks and Mitigations
Section titled “Risks and Mitigations”| Risk | Impact | Likelihood | Mitigation |
|---|---|---|---|
Official SDK cannot parse GET /templates |
High | High today | Gate-on response is the E2B array. Conformance test decodes it with the pinned SDK models. |
| Rebuild replaces the image out from under a running sandbox | High | Med | status.readyImage changes only when the new build is ready. Running Pods keep the image they started with. The upper block device is not rewritten. |
| Build context or registry password lands in the sandbox or in logs | High | Med | Context PVC is mounted only by the controller. The source-pull Secret is deleted at the end of the build. The destination pull Secret is cluster config and is not logged. The log scrubber drops header values and known secret strings. |
| A rebuilt template is handed out from a warm standby that still runs the previous image | High | Med | When readyImage changes, unclaimed standbys for that template are deleted and the pool stamps new ones. ClaimWarmPod rejects an entry whose image does not match the current readyImage. Claimed sandboxes and their PVCs stay. |
Files baked at /workspace disappear once the workspace volume mounts |
High | Med | COPY and WORKDIR onto /workspace are rejected. The workspace volume is not seeded from the image. |
RUN as root during build produces root-owned files the sandbox user cannot write |
Med | High | Documented. Default sandbox user remains 1000:1000. Authors who USER another account get that account for agent exec. The probe runs the ready command as that user. |
| BuildKit cache grows without bound on the node | Med | Med | Cache PVC has a size limit in Helm. Eviction is BuildKit’s own GC (gckeepstorage). Cache loss only slows the next build. |
| Controller restarts mid-build | Med | Med | The CR keeps the phase. Restart sets error with reason builder disappeared and does not adopt the in-flight solve. The BuildKit Deployment and its cache PVC stay. readyImage is unchanged. The client retries against the warm cache. Local mode keeps watching a docker build pid that is still alive. |
startCmd semantic differs from an E2B memory snapshot |
Med | High | Stated in the proposal. Ready command runs against a freshly started process. Templates that depend on in-memory state captured at snapshot time are out of scope. |
| Deleting a template strands a warm pool | Med | Med | Delete returns 409 when a PodWarmPool or a non-terminated PodBox references the template. ?force=true removes the catalog row only. Volumes are not deleted. |
Status route returns uploaded while the probe runs |
High | High if unmapped | The CR stores uploaded. The status and list routes report building until ready or error. |
Two different bodies arrive for one filesHash |
Med | Low | The first complete body is stored. A later body with different bytes returns 409 and does not replace it. The gateway does not compare the body to the SDK’s filesHash. |
Design Details
Section titled “Design Details”API / CRD / Wire Changes
Section titled “API / CRD / Wire Changes”Feature gate E2BTemplateAPI (default off). Gateway flag --feature-gate=E2BTemplateAPI=true, Helm featureGates.e2bTemplateAPI. With the gate off, no new route is registered and GET /templates stays the current stub. The gate is read by cubepod-gateway and cubepod-controller only. podd has no gate. It runs CUBEPOD_START_CMD when the controller sets that variable on the Pod, and it does nothing when the variable is absent.
PodTemplate spec additions
Section titled “PodTemplate spec additions”All new fields are optional. spec.image stays required so existing objects still validate. For a template that is only waiting, spec.image is set to the placeholder cubepod.io/unbuilt:waiting. Sandbox create rejects that placeholder with 409 template is not ready.
// Additions to PodTemplateSpec in api/v1alpha1/types.go.type PodTemplateSpec struct { // existing: RuntimeClassName, Image, PoddImage, Resources, Storage, Egress
// Names are the E2B aliases and tags ("my-template", "my-template:v1", "ubuntu:24.04"). Names []string `json:"names,omitempty"` // CPUCount, MemoryMB, and DiskSizeMB are the E2B resource view. // The controller also writes Resources and Storage.rootfsSize from them. CPUCount int32 `json:"cpuCount,omitempty"` MemoryMB int32 `json:"memoryMB,omitempty"` DiskSizeMB int32 `json:"diskSizeMB,omitempty"` Public bool `json:"public,omitempty"` // StartCmd is launched by podd after pivot_root. It is not the container entrypoint. StartCmd string `json:"startCmd,omitempty"` // ReadyCmd is probed during the build and after each sandbox start. Empty skips the probe. ReadyCmd string `json:"readyCmd,omitempty"` // RunAsUser is the uid from a numeric USER step. Nil means unset and podd uses 1000. // A pointer is required so USER 0 (root) is distinct from unset. RunAsUser *int64 `json:"runAsUser,omitempty"` // RunAsGroup is set when USER is "<uid>:<gid>" and gid is numeric. RunAsGroup *int64 `json:"runAsGroup,omitempty"` // RunAsUsername is set when USER is not a bare integer. podd resolves it inside the image. RunAsUsername string `json:"runAsUsername,omitempty"`}PodTemplate status
Section titled “PodTemplate status”The CRD gains subresources.status. Existing objects get an empty status.
type PodTemplateStatus struct { TemplateID string `json:"templateID,omitempty"` ReadyBuildID string `json:"readyBuildID,omitempty"` ReadyImage string `json:"readyImage,omitempty"` LatestBuildID string `json:"latestBuildID,omitempty"` // BuildStatus is the E2B enum for LatestBuildID: // waiting, building, uploaded, ready, error. BuildStatus string `json:"buildStatus,omitempty"` Reason string `json:"reason,omitempty"` FailedStep string `json:"failedStep,omitempty"` Public bool `json:"public,omitempty"` BuildCount int64 `json:"buildCount,omitempty"` SpawnCount int64 `json:"spawnCount,omitempty"` CreatedAt string `json:"createdAt,omitempty"` UpdatedAt string `json:"updatedAt,omitempty"` LastSpawnedAt string `json:"lastSpawnedAt,omitempty"`}
// PodTemplate is the cluster object. The shape matches PodBox in this package.// This module does not run controller-gen, so these structs are hand-written.type PodTemplate struct { APIVersion string `json:"apiVersion,omitempty"` Kind string `json:"kind,omitempty"` Metadata ObjectMeta `json:"metadata"` Spec PodTemplateSpec `json:"spec"` Status PodTemplateStatus `json:"status,omitempty"`}
// PodTemplateList is the list returned by the CRD.type PodTemplateList struct { APIVersion string `json:"apiVersion,omitempty"` Kind string `json:"kind,omitempty"` Metadata ObjectMeta `json:"metadata,omitempty"` Items []PodTemplate `json:"items"`}api/v1alpha1/types.go today has PodTemplateSpec only. Reconciler.templates is an in-memory map[string]PodTemplateSpec updated by RegisterTemplate. Nothing lists podtemplates.cubepod.io. This CEP adds PodTemplate and PodTemplateList beside PodBox. KubeClient gains ListPodTemplates, GetPodTemplate, CreatePodTemplate, and UpdatePodTemplateStatus. RESTKubeClient talks to the CRD. MemoryKubeClient serves unit tests and the local JSON catalog. On start, and on each resync, the controller lists the CRD and rebuilds the map keyed by metadata.name, status.templateID, and every spec.names entry. RegisterTemplate stays as the write path for tests. The gateway reads templates only through the reconciler.
spec.image is updated to status.readyImage only when a build enters ready. Until then the reconciler that stamps Pods reads status.readyImage when it is non-empty, and falls back to spec.image for objects created by hand with no status.
Printer columns: Image (.status.readyImage), Build (.status.buildStatus), Age.
Alias index
Section titled “Alias index”The controller indexes status.templateID, metadata.name, and every spec.names entry. The primary template name is stored in annotation cubepod.io/primary-name. When POST /v3/templates targets an already-existing template with the same primary name:
- If
status.buildStatus == "building", the request is rejected with409 Conflict(build already in progress). - Otherwise
POST /v3/templatesreusesstatus.templateID, allocates a newbuildID, setsstatus.buildStatus = "waiting", updates requested sizing/commands, keepsstatus.readyImageandstatus.readyBuildIDintact until the new build reachesready, and returns202 Accepted.
A request for a different raw name that collides on a spec.names entry or normalizes to an existing metadata.name slug (when cubepod.io/primary-name differs) is rejected with 409 Conflict. The index is rebuilt from the API list on controller start.
Routes
Section titled “Routes”Alpha and Beta share the catalog. Rows marked Beta return 501 template build is not enabled when --template-build=false, with body {"code":501,"message":"..."}. All E2B routes documented below are also specified in api/openapi/cubepod-v1.yaml.
| Phase | Method | Path | Behavior |
|---|---|---|---|
| Alpha | GET |
/templates |
200 JSON array of E2B template objects. Query teamID is ignored. More than 5000 ready-or-latest rows returns 400, matching E2B’s v1 cap. |
| Alpha | GET |
/templates/{templateID} |
200 TemplateWithBuilds (builds[] entries include buildID, status, createdAt, updatedAt, cpuCount, memoryMB). Unknown id returns 404. |
| Alpha | GET |
/templates/aliases/{alias} |
200 with the template object when the alias exists, 404 when it does not. |
| Alpha | DELETE |
/templates/{templateID} |
204 No Content when unreferenced and not building. 409 when buildStatus == "building" or a non-terminated PodBox or PodWarmPool references it. ?force=true deletes the CR anyway and does not delete volumes. |
| Alpha | PATCH |
/templates/{templateID} and /v2/templates/{templateID} |
200. Body {"public":true|false}. Response {"names":[...]} for the v2 route. |
| Alpha | POST |
/templates, /v2/templates, /v3/templates |
Create or request rebuild. 202 and the v3 response shape below. Idempotency-Key is honored in Beta; Alpha stores the key on the CR and replays the same 202 when the body hash matches. |
| Alpha | POST |
/v2/templates/{templateID}/builds/{buildID} |
202 Accepted. Validates request synchronously (400/404/409/501). Empty steps + empty readyCmd with fromImage or fromTemplate marks the build ready without BuildKit. Non-empty steps or readyCmd runs asynchronously in background. |
| Alpha | GET |
/templates/{templateID}/builds/{buildID}/status |
200 {logs, logEntries, templateID, buildID, status, reason?}. reason is omitted (omitempty) when healthy/in-progress and serialized as an object {"message": string, "step"?: string, "logEntries"?: [...]} (BuildStatusReason) when status == "error". Query logsOffset (entry index), limit (default 100, max 100), level. CR uploaded is returned as building. Unknown templateID/buildID returns 404. |
| Alpha/Beta | GET |
/v2/templates |
200 JSON array of E2B template objects with limit and nextToken cursor pagination (page size 100). Pagination cursor is returned in response header X-Next-Token. |
| Beta | GET |
/templates/{templateID}/files/{hash} |
201 {"present":bool,"url":"<absolute-http(s)-url>","headers":{}}. present: true when that tar is already stored. url MUST be an absolute URL because the e2b SDK uploads with a standalone httpx.Client. |
| Beta | PUT |
the url returned above |
201 Created. Tar body, gzip-compressed when the SDK default applies. Gateway forwards it to the controller when --controller-url is configured. |
| Beta | GET |
/templates/{templateID}/builds/{buildID}/logs |
200 {logs:[{timestamp, message, level}]}. Query cursor, limit, direction, level. |
| Stable | POST / DELETE / GET |
/templates/tags and /templates/{templateID}/tags |
Assign (201), delete (204), and list (200) tags. Alpha and Beta also encode tags as spec.names entries of the form name:tag on the create body (name, tags). |
POST /v3/templates request (the fields the SDK sends):
{ "name": "my-template", "tags": ["v1"], "alias": "my-template", "cpuCount": 2, "memoryMB": 2048, "minFreeDiskMb": 2048}name may contain one colon tag (my-template:v1). That tag is appended to tags. alias is the deprecated name and is used when name is empty.
Response 202:
{ "templateID": "tpl_8f3a0c", "buildID": "bld_1c2d", "public": false, "aliases": ["my-template"], "names": ["my-template", "my-template:v1"], "tags": ["v1"]}templateID is tpl_ plus 8 lowercase hex chars from a random source. buildID is bld_ plus 8 hex chars. Both are stored on the CR. The object name is the slug of the primary name, truncated to 63 characters. A collision on the slug with a different template id returns 409.
POST /v2/templates/{templateID}/builds/{buildID} body is E2B TemplateBuildStartV2:
{ "fromImage": "python:3.13", "fromTemplate": "", "fromImageRegistry": {"type": "registry", "username": "ci", "password": "secret"}, "force": false, "startCmd": "python -m http.server 8080", "readyCmd": "curl -sf http://127.0.0.1:8080/", "steps": [ {"type": "USER", "args": ["1000"]}, {"type": "COPY", "args": ["requirements.txt", "/home/user/requirements.txt"], "filesHash": "<64 lowercase hex>"}, {"type": "RUN", "args": ["pip install -r /home/user/requirements.txt"]} ]}Exactly one of fromImage and fromTemplate is non-empty. fromTemplate must name a template whose buildStatus is ready. The builder uses that template’s readyImage as FROM. Unknown steps[].type returns 400. The types this CEP implements are RUN, COPY, WORKDIR, USER, and ENV. ADD is accepted as COPY. A step with force: true and every step after it skips the cache.
The official SDK lowers helper methods before this request. from_python_image("3.13") becomes fromImage: "python:3.13". from_base_image() becomes fromImage: "e2bdev/base". aptInstall, pipInstall, npmInstall, bunInstall, gitClone, remove, rename, makeDir, and makeSymlink become RUN steps. copyItems and copy of several sources become one COPY step per source. fromDockerfile is parsed by the SDK into the same step list. CubePod does not accept a Dockerfile body and does not see those method names. Extra JSON fields, including maxCpuCount on POST /v3/templates, are ignored.
COPY args are [src, dest], or [src, dest, user, mode] when the SDK sends the four-element form. Empty user and mode are empty strings. RUN args are [command] or [command, user]. ENV args are key/value pairs. USER args are [user]. The rendering rules are in BuildKit translation.
List item shape (every field the E2B list schema marks required, so the official client can decode the array):
{ "templateID": "tpl_8f3a0c", "buildID": "bld_1c2d", "cpuCount": 2, "memoryMB": 2048, "diskSizeMB": 10240, "public": false, "aliases": ["my-template"], "names": ["my-template", "my-template:v1"], "createdAt": "2026-11-12T00:00:00Z", "updatedAt": "2026-11-12T00:00:00Z", "createdBy": null, "lastSpawnedAt": null, "spawnCount": 0, "buildCount": 1, "envdVersion": "cubepod-podd", "buildStatus": "ready"}envdVersion is the constant cubepod-podd. CubePod does not run envd. The field is present because E2B marks it required. createdBy is null. spawnCount increments when a sandbox is created from this template. diskSizeMB is the rootfs quantity converted to MiB.
GET /templates with the gate off is unchanged:
{"templates":[{"id":"cubeplex-ubuntu24-desktop","name":"...","image":"...","description":"..."}]}Seeded templates
Section titled “Seeded templates”On startup the controller ensures three PodTemplate objects if they are absent. It does not overwrite an object the user has edited.
| Object name | spec.names |
spec.image |
Ready |
|---|---|---|---|
cubeplex-ubuntu24-desktop |
cubeplex-ubuntu24-desktop, base, code-interpreter, code-interpreter-v1, python |
cubeplex-ubuntu24-desktop:latest |
yes |
ubuntu-24-04 |
ubuntu:24.04 |
ubuntu:24.04 |
yes |
debian-12 |
debian:12 |
debian:12 |
yes |
CPU 2, memory 2048, rootfs 10Gi, workspace 20Gi, matching defaultTemplate today.
CLI and SDKs
Section titled “CLI and SDKs”cubepod template list|get|create|deletecall the gateway routes above.createin Alpha takes--from-imageand--alias.- Go
pkg/clientgains the same four calls. - Python
list_templates()already accepts an array. It keeps accepting the wrapped object. New methods cover get, create-from-image, and delete. Beta addsbuild_templateonly as a thin helper. The conformance path is the officiale2b.Template.build.
Sandbox create resource fields
Section titled “Sandbox create resource fields”handleCreateSandbox today writes CPUCount: 2, MemoryMB: 2048, DiskSizeMB: 10240. With the gate on, those three fields come from the resolved template. With the gate off they stay hardcoded.
Architecture & State Machine
Section titled “Architecture & State Machine”sequenceDiagram
participant SDK as e2b SDK
participant GW as cubepod-gateway
participant CTL as cubepod-controller
participant BK as BuildKit Deployment or dockerd
participant REG as OCI registry or local image store
SDK->>GW: POST /v3/templates
GW->>CTL: create PodTemplate status=waiting
CTL-->>SDK: 202 templateID buildID
SDK->>GW: GET /templates/{id}/files/{hash}
GW-->>SDK: 201 present=false url
SDK->>GW: PUT context tar
GW->>CTL: store tar keyed by hash
SDK->>GW: POST /v2/templates/{id}/builds/{buildID}
CTL->>CTL: render Dockerfile from steps
CTL->>BK: build and push
BK->>REG: image tpl:buildID
CTL->>CTL: status=uploaded
CTL->>CTL: probe Pod or docker run, readyCmd
CTL->>CTL: status=ready, spec.image=readyImage
SDK->>GW: POST /sandboxes templateID=alias
GW->>CTL: PodBox with that image, new rootfs PVC, new or bound workspace PVCBuild phases on status.buildStatus:
stateDiagram-v2
[*] --> waiting: POST /v3/templates
waiting --> ready: fromImage or fromTemplate, no steps, no readyCmd
waiting --> building: steps or readyCmd
building --> error: BuildKit or dockerd fails
building --> uploaded: image pushed
uploaded --> ready: probe passes or readyCmd empty
uploaded --> error: probe times out
ready --> building: a later build starts
error --> building: a later build startsA transition to building does not clear readyImage or readyBuildID. Sandbox create and warm-pool stamping read readyImage only.
uploaded is an internal phase on the CR. The HTTP status and list fields report it as building. See Where the builder runs.
podd reads the template user from the sandbox Pod environment. CUBEPOD_START_CMD is the start command. CUBEPOD_RUN_AS_USER and CUBEPOD_RUN_AS_GROUP are set only when the corresponding pointer is non-nil, including when the value is 0. CUBEPOD_RUN_AS_USERNAME is set when runAsUsername is non-empty. Absent user env means 1000:1000.
podd-init still execs /opt/cubepod/bin/podd, so podd is PID 1. The template image’s ENTRYPOINT is not used. When the Pod env CUBEPOD_START_CMD is set, podd starts that command once as the template user and does not restart it after it exits. stdout and stderr are appended to /var/log/cubepod/start-cmd.log on the rootfs upper, mode 0644, owned by the template user. The exit code is written to /var/log/cubepod/start-cmd.status. podd reaps startCmd and any children it leaves behind, because those SIGCHLD signals are delivered to PID 1. The log lives on the rootfs upper: reset-rootfs removes it, and the workspace volume is not involved.
The build probe polls readyCmd inside the short-lived probe Pod. On sandbox create, the gateway polls the same command through podd until it exits 0 or templateBuild.probeTimeout (default 120s) elapses. On timeout the PodBox phase becomes Failed and the API returns 503:
{ "code": 503, "message": "template ready command timed out", "sandboxID": "cpod-1", "logPath": "/var/log/cubepod/start-cmd.log"}The sandbox is not deleted, so the file API can read that log. An empty readyCmd skips the probe. The gateway’s existing podd health wait still runs.
Where the builder runs
Section titled “Where the builder runs”Production Beta does not start a Pod or a Job for each build. Helm installs one long-lived Deployment, cubepod-buildkit, in the same namespace as the controller and the gateway (cubepod-system on the test cluster). replicas is 1. The container image is moby/buildkit:rootless (Helm templateBuild.buildkit.image). The process is rootless buildkitd, listening on tcp://0.0.0.0:1234. The Service DNS is cubepod-buildkit.cubepod-system.svc. Helm templateBuild.buildkit.address defaults to tcp://cubepod-buildkit.cubepod-system.svc:1234.
The Pod does not set runtimeClassName. containerd starts it with runc, the same way it starts cubepod-controller. It is not a Kata MicroVM and it does not take a warm-pool slot. Helm templateBuild.buildkit.nodeSelector defaults to {}. An operator whose default runtime is Kata, or whose nodes cannot create user namespaces, sets that selector so the Pod lands on a runc node. The container security context follows the upstream rootless example (moby/buildkit examples/kubernetes/pod.rootless.yaml): runAsUser: 1000, runAsGroup: 1000, seccompProfile.type: Unconfined, appArmorProfile.type: Unconfined. It is not privileged and it does not mount docker.sock. The args are --addr tcp://0.0.0.0:1234, --oci-worker-no-process-sandbox, --oci-worker-gc, and --oci-worker-gc-keepstorage sized to the cache volume. --oci-worker-no-process-sandbox can leave a process in this container after a RUN finishes. That process is inside the builder, not inside a sandbox. Restarting the Deployment clears it. The cache volume keeps the layers across that restart.
The cache volume is PVC cubepod-buildkit-cache, default 20Gi, mounted only on this Pod at /home/user/.local/share/buildkit. BuildKit GC (gckeepstorage) keeps usage inside that size. This is the hot cache. BuildKit also imports and exports the registry cache ref <registry>/cubepod-templates/<slug>-cache, so a Pod that is rescheduled onto an empty volume can refill it. The context tar PVC cubepod-template-context is a different volume. It is mounted only on the controller.
The controller process is the BuildKit client, through the BuildKit Go client linked into cubepod-controller. There is no buildctl sidecar. Registry credentials for one solve are BuildKit session secrets. They are not written into the daemon config and they are not mounted into the BuildKit Pod. A NetworkPolicy allows only the controller to open port 1234.
The ready probe is a different, short-lived runc Pod. It runs after the image is already in the registry. It does not build.
Local docker-compose does not install this Deployment. The in-process reconciler calls pkg/runtime for docker build on the host Docker daemon.
When the controller restarts while the CR says building, it does not adopt the in-flight solve. The client session is gone, so BuildKit cancels that solve. The controller sets error with reason builder disappeared. The Deployment and the cache PVC stay. readyImage stays. The SDK retries, and the next solve uses the cache already on the PVC. Local mode records the docker build pid and keeps watching it when the process is still alive. If the registry already has the build tag and the controller died after the push, before it wrote uploaded, restart moves the CR to uploaded and runs the probe.
The controller appends scrubbed build lines to logs/<buildID>.log on the context volume. Locally that path is ${CUBEPOD_STATE}/template-context/logs/<buildID>.log. logsOffset counts entries already returned. The file is kept for contextTTL (default 24h).
BuildKit translation
Section titled “BuildKit translation”The controller renders a Dockerfile in memory. It does not write it into the user’s workspace. The paths in the example are only there to show the translation. Any path other than /workspace is acceptable.
The SDK request for from_python_image("3.13").copy("requirements.txt", "/home/user/requirements.txt").run_cmd("pip install -r /home/user/requirements.txt") arrives with fromImage: "python:3.13" and two steps. With filesHash abc123 (shortened here) the rendered file is:
FROM python:3.13COPY abc123/requirements.txt /home/user/requirements.txtRUN pip install -r /home/user/requirements.txtEvery distinct filesHash tar is unpacked into one BuildKit context directory, under a prefix named by that hash. Two steps that share a hash share one unpacked tree. The rendered COPY source is <filesHash>/<src>, where <src> is args[0]. A source that is absolute or contains .. returns 400. A tar member that would extract outside its prefix returns 400 and the build does not start.
Rules:
Step type |
Dockerfile | Notes |
|---|---|---|
ENV |
none | args is a list of key, value, key, value. An odd length returns 400. Values are tracked in an ordered map (later keys override earlier keys), POSIX single-quote escaped, and prefixed onto later RUN lines as export KEY='VAL'; so compound cmd1 && cmd2 invocations and values with spaces work without emitting an image ENV. |
WORKDIR |
WORKDIR |
A value of /workspace or a path under it returns 400. |
USER |
USER |
The arg is copied unchanged. 1000 sets runAsUser. 0 sets runAsUser to 0. 1000:1000 sets runAsUser and runAsGroup. Any other token sets runAsUsername and leaves the pointers nil. |
COPY / ADD |
COPY |
args[0] is the source inside the tar, args[1] is the destination. Non-empty args[2] becomes --chown. Non-empty args[3] becomes --chmod and must match ^[0-7]{3,4}$. Missing tar returns 400 before the build starts. A destination of /workspace or a path under it returns 400. |
RUN |
RUN |
args[0] is the shell command, prefixed with the accumulated build-time env. Non-empty args[1] is the user for that command only: the renderer emits USER <args[1]> before the RUN and USER <previous> after it. <previous> is the last USER step, or it is omitted when no USER step has been seen. This does not change runAsUser. A RUN that writes under /workspace is not rejected by parsing the shell. |
startCmd |
not a CMD |
Passed to podd through CUBEPOD_START_CMD. |
readyCmd |
not a RUN |
Probe after push. |
force: true on the build request invokes BuildKit with no cache. force: true on a single step inserts
ARG CUBEPOD_CACHEBUST=<buildID>immediately before that instruction, which invalidates that instruction and every instruction after it. Earlier instructions stay cached. This matches E2B’s skipCache() placement.
Layer cache in production uses BuildKit’s registry cache, ref <registry>/cubepod-templates/<slug>-cache. Local Docker uses the daemon’s normal layer cache.
The rendered Dockerfile and the context tars are deleted after the build reaches ready or error, except the content-addressed tar store keeps objects for contextTTL (default 24h) so a retry with the same filesHash gets present: true.
Sandbox resolution
Section titled “Sandbox resolution”With the gate on, POST /sandboxes resolves templateID in this order:
- An empty
templateIDis rewritten tobasebefore the steps below. Today’s gateway treats""the same asbase,code-interpreter,code-interpreter-v1, andpython(pkg/gateway/sandboxes.go). Leaving the empty string to fall through to step 5 would return 404. - Alias index hit with
buildStatus=ready(or a non-emptyreadyImage): usereadyImage, CPU, memory, rootfs size,startCmd,readyCmd, and run-as user. - Alias index hit without
readyImage: 409template is not ready. - No hit, and the string is one of
base,code-interpreter,code-interpreter-v1,python: use the seededcubeplex-ubuntu24-desktoptemplate. This preserves CEP-0018. - No hit, and the string contains
/or:, so it looks like an OCI reference: keep today’sdefaultTemplatebehavior and stamp a Pod from that image. This keepstemplateID: "ghcr.io/org/app:1.2"working without a catalog row. The literal placeholdercubepod.io/unbuilt:waitingis not an OCI fallback. It returns 409template is not ready. - Otherwise 404
template not found.
With the gate off, step 0 still runs, and steps 1, 2, and 5 are skipped. The current rewrite plus defaultTemplate stays.
Warm-pool create uses the same resolver. A pool bound to a template that is not ready is rejected.
Dual-Runtime Behavior (K8s Kata vs. Local Docker)
Section titled “Dual-Runtime Behavior (K8s Kata vs. Local Docker)”Kubernetes Production (Kata + CSI raw block)
- Catalog state is the
PodTemplateCR in the workload namespace. - Alpha from-image registration does not start a pod. The next sandbox Pod uses
spec.image/status.readyImageas the container image. containerd pulls it.podd-initmounts the newrootfs-pvcblock device as the OverlayFS upper and mountsworkspace-pvcon/workspace. Neither volume is copied from the template. - Beta runs the BuildKit Deployment described in Where the builder runs. The controller streams the context from the RWO PVC
cubepod-template-context, mounted only by the controller. When Gateway runs separately (--controller-url), Gateway forwards context checks/uploads, build triggers, and build log reads to the controller (HEAD/PUT /internal/v1/template-builds/context/{hash},POST /internal/v1/template-builds/{templateID}/{buildID}/start,GET /internal/v1/template-builds/{buildID}/logs), the same style as/internal/v1/egress/exchange. The body is gunzipped when it starts with the gzip magic1f 8b. Anything that is neither gzip nor a tar returns 400 and is not stored. A second body whose bytes differ from the stored tar returns 409. - While
templateBuild.enabled=true, the controller Deployment isreplicas: 1because the context PVC is RWO. Stable removes this cap by storing tars in an object store. - The pushed image reference is
<registry>/cubepod-templates/<slug>:<buildID>. BuildKit pushes it withtemplateBuild.registry.pushSecret. Sandbox Pods and the probe Pod listtemplateBuild.registry.pullSecretunderimagePullSecrets. That pull Secret is created by the cluster install, not by the build request, and the controller does not delete it.fromImageRegistryis a different, build-scoped Secret used only to pull the base image. It is not attached to sandbox Pods. - The ready probe is a short-lived runc Pod, not a Kata PodBox. It uses the new image, an
emptyDirfor writable space, norootfs-pvc, and noworkspace-pvc. The probe command startsstartCmdin the background and pollsreadyCmdagainst localhost. The controller deletes the Pod when the probe finishes. A probe failure does not allocate a CSI volume. - Sandbox Pods that are created after
readystill go through Kata,podd-init, and the dual block PVCs.CUBEPOD_START_CMDis set on that Pod only.
Local Development (Docker + loopback ext4)
The reconciler is the builder in both modes. cmd/cubepod-gateway already constructs that reconciler in-process for local runs, and the HTTP handlers do not shell out. pkg/runtime is the Docker client the reconciler calls. It does not decide when a build starts.
- Catalog state is a JSON file under the local state dir (
${CUBEPOD_STATE}/templates/<templateID>.json) with the same fields as the CR status. The in-process reconciler writes it throughMemoryKubeClient. There is no Kubernetes API. - Alpha from-image registration records the image name. The reconciler then asks
pkg/runtimetodocker runthat name, as it does today. - Beta, when no registry is configured, the reconciler calls
pkg/runtimetodocker buildon the host Docker socket the local driver already uses. The tag iscubepod-templates/<slug>:<buildID>. The context dir is${CUBEPOD_STATE}/template-context/<hash>/. This socket is not mounted into sandbox containers. - The probe is the reconciler calling
pkg/runtimefordocker run --rmof that tag, with a local scratch upper and no loopback file attached for the workspace. The container is removed after the probe. - When a local registry is configured, the same reconciler path pushes and
pkg/runtimepulls, same as production.
Boundary Conditions & Failure Recovery
Section titled “Boundary Conditions & Failure Recovery”| Condition | Result |
|---|---|
| BuildKit or dockerd returns non-zero | buildStatus=error, reason is the last non-empty log line. readyImage unchanged. Context tar kept until TTL. |
Controller process dies while building |
On start, set error with reason builder disappeared. Do not adopt the BuildKit solve. The Deployment stays up. Local mode keeps watching the recorded docker build pid when it is still running. |
| Gateway dies during tar upload | Client retries PUT. A partial file is named *.partial and is not present. A completed object is replaced only when the new body has the same bytes. |
| Idempotency-Key replay with the same body hash | Return the stored 202 body and the same ids. Do not start a second build. |
| Idempotency-Key replay with a different body | 409 idempotency_request_mismatch. |
Probe exceeds templateBuild.probeTimeout (default 120s) |
error. Probe Pod or container is deleted. |
| Node disk full on the cache PVC | BuildKit fails the build. User workspace PVCs are a different volume. |
| Registry push succeeds and the process dies before status update | On restart, if the image tag exists (crictl/docker manifest inspect or registry HEAD), move uploaded and resume the probe. |
Delete while a build is building |
409. The build is left running until it finishes, then the operator deletes. |
Base image pull requires auth and fromImageRegistry is missing |
Build fails with error. The message says the pull was unauthorized and does not include credentials. |
Test Plan
Section titled “Test Plan”- Unit tests (
cargo testincrates/podd):CUBEPOD_START_CMDunset:podddoes not spawn a start process.CUBEPOD_START_CMDset: the command runs once as the configured uid, stdout and stderr land in/var/log/cubepod/start-cmd.log, the exit code lands instart-cmd.status, and the process is not restarted.- A child that exits is reaped.
podddoes not leave a zombie.
- Unit tests (
go testinpkg/gateway,pkg/controller):- Gate off:
GET /templatesbody is still the wrapped three-entry stub. - Gate on: list decodes as a JSON array with the required E2B fields. Seeded aliases resolve.
- Create from
fromImagewith empty steps setsreadyand does not invoke the builder interface. - Create with steps and the Beta builder disabled returns 501.
- Alias collision returns 409. Unknown step type returns 400. Both
fromImageandfromTemplateset returns 400. Neither set returns 400. filesHashthat is not 64 hex chars returns 400. A gzip upload is stored as a tar andpresentbecomes true. A second, different body for that hash returns 409. A partial file stayspresent: false.- Status while the CR is
uploadedisbuilding.USER 0setsrunAsUserto 0. An omittedUSERleaves both pointers nil. - Two
COPYsteps with different hashes renderCOPY <hash>/<src> <dest>and unpack under those prefixes.COPYargs[src, dest, "appuser", "0644"]render--chown=appuser --chmod=0644.RUNargs["npm install", "node"]wrap thatRUNinUSERlines and do not changerunAsUser. - Rebuild failure does not change
readyImage. Sandbox create duringbuildingstill uses the old image. - Delete of a template referenced by a PodBox returns 409 and the CR remains.
- Dockerfile renderer:
ENVdoes not appear as anENVinstruction.RUNlines contain the prefix. A stepforceinsertsARG CUBEPOD_CACHEBUSTbefore that step only. - Idempotency replay returns the same
templateIDandbuildID. - Log scrubber removes a fixture password.
defaultTemplatefallback still acceptsghcr.io/org/app:1when the gate is on and the name is not in the catalog.basestill resolves tocubeplex-ubuntu24-desktop. An emptytemplateIDresolves to the same template when the gate is on.COPYorWORKDIRof/workspacereturns 400 and does not call the builder.- A
readyImagechange deletes unclaimed warm entries for that template andClaimWarmPodwill not return an entry stamped with the previous image. A claimed PodBox and its PVCs remain. - The sandbox Pod manifest references
templateBuild.registry.pullSecret. The source-pull Secret name is absent from that manifest.
- Gate off:
- Integration / E2E:
- Local Docker:
POSTa from-image template,POST /sandboxeswith the alias,poddanswers health, a file written under/workspaceis still there afterreset-rootfs. The template image id does not change that file. - Local Docker Beta: build
FROM busybox,RUN echo cube > /etc/cubepod-build, probe withreadyCmdoftrue. New sandboxcat /etc/cubepod-buildprintscube. A second sandbox has an empty upper and does not see a file written in the first sandbox’s upper. - Kubernetes: the same two flows against the test cluster. The
cubepod-buildkitPod and the ready-probe Pod are runc. The sandbox Pod is Kata (or the cluster’s configured runtime class) and has two PVCs. Deleting the template CR does not delete those PVCs. Restarting the controller duringbuildingsetserrorwith reasonbuilder disappearedand leaves the BuildKit Pod running. - Conformance: with
E2B_DOMAINpointed at the gateway, the pinnede2bPython SDKTemplate.buildof an image-only recipe (Alpha) and of arun_cmdpluscopyrecipe (Beta) returns, andSandbox.create(alias)runs a command. The test records the SDK version in the test file.
- Local Docker:
Graduation Criteria
Section titled “Graduation Criteria”- Alpha (
v0.4.0): Gate off by default. Gate on passes the catalog tests, from-image registration, sandbox resource fields, seeded aliases, and the local plus Kubernetes from-image E2E. Delete cannot remove a referenced template. No BuildKit Deployment is installed unlesstemplateBuild.enabled=true, and Alpha Helm leaves that false. - Beta (
v0.5.0): Gate still off by default.Template.buildconformance passes on local Docker and on the test cluster. A failed rebuild keeps the previous image. Probe Pods are garbage-collected. Registry credentials do not appear in gateway logs or indocker history/ image config of the built image. Metrics below are exported. - Stable (
v1.0.0): Gate on by default after a CubePlex bake in which a template rebuild did not modify an existing workspace PVC (verified by a checksum of a marker file). Controller can run with more than one replica, with context tars in object storage. Tag routes match the pinned SDK. Rollback of the gate is still supported.
Upgrade / Downgrade Strategy
Section titled “Upgrade / Downgrade Strategy”- CRD change is additive: new optional spec fields and a status subresource. Old controllers ignore unknown fields. New controllers tolerate objects with no status and use
spec.image. - Upgrading the gateway with the gate off does not change
GET /templatesor sandbox create. Enabling the gate changesGET /templatesto an array. The Python SDK accepts both. A client that only parses the wrapped object must be updated before the gate is turned on. One release of the gateway documents this in the Helm values comment. - Disabling the gate hides the new routes and restores the stub list.
PodTemplateobjects, sandbox PVCs, and running Pods are left in place. Images already pulled stay pulled. - Downgrade of the controller to a build that does not know status: sandbox stamping falls back to
spec.image. This CEP writesspec.imagewhen a build becomes ready, so a downgrade still boots the last ready image. - No migration of
rootfs-pvcorworkspace-pvc. Rootfs generation is not bumped by a template build. - Base-image rollout of a template is a new build id. Existing sandboxes keep their upper. Operators who want a clean lower start a new sandbox or call the existing reset-rootfs API, which replaces the upper and still keeps
/workspace.
Production Readiness Review Questionnaire (PRR)
Section titled “Production Readiness Review Questionnaire (PRR)”Feature Enablement and Rollback
Section titled “Feature Enablement and Rollback”Helm featureGates.e2bTemplateAPI and templateBuild.enabled. Rollback is setting the gate to false and redeploying the gateway. Rollback does not delete PodTemplate objects and does not touch sandbox PVCs. A build in flight is marked error on controller restart if the builder is scaled to zero.
Rollout, Upgrade and Rollback Planning
Section titled “Rollout, Upgrade and Rollback Planning”Gateway and controller roll independently. In-flight sandbox Pods are not restarted by a template rollout. Paused sandboxes keep their PVCs and, on resume, boot the image they were created with (PodBox stores the resolved image in an annotation cubepod.io/resolved-image written at create time). They do not silently jump to a newer template build.
When status.readyImage changes, the controller deletes unclaimed warm-pool entries whose stamped image differs. That delete removes the standby Pod and the unused rootfs and workspace PVCs that the pool created for it. Those volumes were never bound to a user sandbox. ClaimWarmPod checks the entry image against the current readyImage and returns an error instead of handing out a stale standby. The warm-pool reconciler then creates replacements from the new image until replicas is met. A PodBox that was already claimed, and every PVC it owns, is left in place.
Monitoring Requirements
Section titled “Monitoring Requirements”cubepod_templates{status}gauge, replacing the hardcodedcubepod_gateway_templates_totalvalue of 3 when the gate is on. With the gate off the old metric stays.cubepod_template_builds_total{result="ready|error"}counter.cubepod_template_build_duration_secondshistogram.- Controller log line
template build finishedwithtemplateID,buildID,result, and no registry headers. - Health: BuildKit Deployment readiness is included in the controller’s
/healthzonly whentemplateBuild.enabled=true. A down builder fails new builds and does not fail sandbox create for templates that are alreadyready.
Dependencies
Section titled “Dependencies”- Production builds: rootless BuildKit, an OCI registry the nodes can pull (Harbor, Zot, the
registryimage, or an external registry), containerd, and the existing Kata runtime plus CSI block driver for the sandboxes themselves. The builder does not depend on Longhorn or on a particular CSI. - Local builds: the Docker daemon already required by
pkg/runtime. - No new kernel module. Overlay and ext4 requirements are unchanged from CEP-0001.
Scalability
Section titled “Scalability”- Catalog reads are CR list calls. The 5000-row v1 cap returns 400 and points clients at
GET /v2/templates. - One build at a time per controller replica in Beta, queued on the controller work queue. A second build for the same template returns 409 while
latestiswaitingorbuilding. - BuildKit cache PVC default
20Gi. Sandbox density is unchanged because builds do not consume warm-pool slots or CSI attach slots on the Kata nodes. - Context tar limit default
512Miper hash. Larger uploads are 413.
Troubleshooting
Section titled “Troubleshooting”| Symptom | What to look at | Recovery |
|---|---|---|
GET /templates is still the wrapped stub |
Gate is off | Enable E2BTemplateAPI and restart the gateway |
SDK polls building until timeout |
cubepod-buildkit Pod logs, controller log template build |
Fix the recipe. readyImage of the previous build is still used for create |
Template.build throws Unknown build error occurred while the CR is uploaded |
Status route returned the CR value uploaded |
That route must report building until ready or error |
Build error reason builder disappeared |
Controller restarted during the solve | Retry the build. The BuildKit Deployment and the cache PVC are still there |
Sandbox 409 template is not ready |
kubectl get podtemplate -o yaml status |
Wait for ready, or create from an alias that has readyImage |
COPY 400 missing context |
present was false and the PUT did not complete |
Re-run the SDK build. The client uploads again |
Probe error with a good image push |
Probe Pod logs before deletion, also status reason |
readyCmd must succeed against localhost with no dependency on podd |
| Alias 409 | Two templates claim the same spec.names entry |
Delete or rename one. Force-delete does not delete volumes |
Sandbox create 503 template ready command timed out |
/var/log/cubepod/start-cmd.log inside that sandbox |
Fix startCmd or readyCmd. The sandbox stays so the log can be read. It is phase Failed. |
Build 400 on /workspace |
The COPY or WORKDIR path |
Put the files on any path other than /workspace. The workspace volume is the sandbox’s own PVC. |
Implementation History
Section titled “Implementation History”- 2026-11-12: Proposal drafted (
provisional). Prior art reviewed against the E2B OpenAPI ondocs.e2b.dev, the E2B template manager ine2b-dev/infra, BuildKit,kubernetes-sigs/agent-sandboxSandboxTemplate, and Docker Sandboxes OCI templates. - 2026-11-12: Review revisions. Source-pull credentials are build-scoped. The template registry pull Secret stays on sandbox Pods. Template steps must not use
/workspace, and no other directory is prescribed.PodTemplateandPodTemplateListare specified, and the in-memory map is filled from the CRD. A newreadyImageretires unclaimed warm standbys. An emptytemplateIDbecomesbase.startCmdlogs, reaping, and the create-time 503 are specified. Local builds are driven by the in-process reconciler throughpkg/runtime.poddis not a feature-gate component.GET /templates/{templateID}/files/{hash}stays 201, which is the status in the E2B OpenAPI. - 2026-11-12: Builder placement and SDK step translation. The builder is one runc Deployment in
cubepod-system, not a per-build Job. Controller restart fails the in-flight solve and leaves the daemon and cache PVC in place.filesHashis the SDK’s content hash, and the upload body is a gzip tar. HTTP status reports the internaluploadedphase asbuilding, because the JS SDK stops polling on any other value.COPYsteps share one BuildKit context, unpacked under the hash prefix.USER 0is root. - 2026-11-13: Wire contract review amendments (
implementable). ClarifiedBuildStatusReasonobject shape (reasonomitted when empty,{"message", "step"}onerror), same-name rebuild reuse oftemplateIDinPOST /v3/templates, Alpha inclusion ofGET .../status, POSIX single-quotedexport KEY='VAL';prefix for build-timeENV,202/204/201status codes,TemplateWithBuildsfields,PodTemplateStatuscounters, and controller internal build/context endpoints (HEAD/PUT /internal/v1/template-builds/context/{hash},POST /internal/v1/template-builds/{templateID}/{buildID}/start,GET /internal/v1/template-builds/{buildID}/logs). Synced OpenAPI spec inapi/openapi/cubepod-v1.yaml.
Drawbacks
Section titled “Drawbacks”- Two artifacts now exist for “what a sandbox boots”: the OCI image and the per-sandbox upper. Operators who expect an E2B-style resumed snapshot will see a fresh
startCmdon every create. That difference has to stay visible in the docs. GET /templatescannot keep a single body shape that satisfies both the official SDK and the old wrapped stub. The gate exists because of that.- A single-replica controller while builds are enabled is an operational limit. It is the cost of not adding an object store in Beta.
- Rootless BuildKit is another Deployment to patch and to give disk to. Clusters that only want to pin existing images never need it, which is why Alpha does not install it.
- Matching E2B’s build-time-only
ENVmeans a DockerfileENVauthor who switches fromdocker buildtoTemplate.buildwill not see those variables at runtime. This CEP follows the SDK contract, notdocker build.
Alternatives Considered
Section titled “Alternatives Considered”-
Port the E2B template manager and store Firecracker snapshots. Rejected. The open-source builder pulls an image, injects systemd and
envd, boots Firecracker, and uploads a memory-and-disk snapshot (e2b-dev/infraorchestrator template build). CubePod’s lower layer is an OCI image unpacked by containerd, PID 1 ispodd-initinjected fromemptyDir, and the writable layer is a CSI block volume. A snapshot store would be a second rootfs implementation beside OverlayFS, and it would bake a guest agent into the image, which the repository invariants forbid. Self-hosted E2B stacks also require/dev/kvmon the build host and a copy of that control plane. That is a different product. -
Keep the current behavior and document “push an image, pass its name as templateID”. Rejected as the whole fix. It remains the fallback in step 4 of sandbox resolution, because it is useful. It does not implement list, alias, build status, or
Template.build. -
Add a new CRD copied from
kubernetes-sigs/agent-sandboxSandboxTemplate. Rejected. That project is the right reference for “a template is a pod recipe plus an image, and a warm pool stamps it”. CubePod already hasPodTemplateandPodWarmPoolfor that. A second CRD would split the image reference the reconciler reads. -
Build with Kaniko. Rejected. Kaniko is unmaintained. BuildKit is the rootless builder Kubernetes and Docker tooling use, including registry-backed layer cache. A Job-per-build Kaniko pod also cannot share a warm cache without extra registry round trips, which BuildKit’s daemon already does.
-
Run rootless BuildKit as a Kubernetes Job per build (
buildctl-daemonless). Rejected for Beta.Template.buildis iterative: the next recipe often changes oneRUN. A residentbuildkitdkeeps its local layer cache open. A Job pays Pod scheduling, rootless startup, and a cache-database open on every build, and it still needs the same unconfined seccomp profile. A Job that does not mount the cache PVC also pushes and pulls the registry cache on every build. The control plane would be smaller, and this remains a reasonable later choice for a cluster that builds rarely. It is the wrong default for the SDK workflow this CEP serves. -
Commit a running sandbox (
docker commitor a CSI snapshot ofrootfs-pvc) and call that a template. Rejected for this CEP. The upper can contain workspace-adjacent writes, shell history, and files the agent downloaded. E2B separates that artifact (sandbox snapshot) from a template rebuilt from a recipe. CSI snapshots are also driver-specific (Longhorn versus loopback) and would not exist in local Docker the same way. A later CEP can add an explicit snapshot API. -
Run the build steps inside a Kata PodBox and snapshot the upper block device. Rejected. It couples template build latency to CSI attach, risks leaving a half-written ext4 if the Pod is killed, and copies the upper into the next sandbox’s lower by a driver-specific clone. BuildKit already produces a content-addressed image the node can pull. The per-sandbox upper stays empty at create, which is the dual-volume model.
-
Write template
ENVinto the image config so it matchesdocker build. Rejected for the E2B-compatible path. The currente2bSDK documents those variables as build-time only. Runtime env stays onPOST /sandboxes. Authors who want image-level env can put an explicitRUNthat writes a file the process reads.