From 432939398b9df93a0ef87ad1085df5e524af10d5 Mon Sep 17 00:00:00 2001 From: AdaAibaby Date: Wed, 15 Jul 2026 18:13:54 +0800 Subject: [PATCH 1/4] feat(orchestrator): add export-build command to export template rootfs as Docker image Implements issue #3272. Adds a new CLI tool packages/orchestrator/cmd/export-build that reads an E2B template rootfs from object storage via the NBD block-device pipeline and writes it as a Docker-loadable OCI tar archive or pushes it directly to a container registry. Key changes: - cmd/export-build/main.go: new command with -build/-template/-storage/ -output/-push/-tag flags. Storage and API endpoint are env-configurable so the minimal invocation is just two flags: sudo -E export-build -template -output /tmp/rootfs.tar - cmd/internal/cmdutil/template.go: extract resolveAPIURL() and add E2B_API_URL env var (highest priority) so self-hosted deployments with a non-standard API host (e.g. http://localhost:3000) work without a domain suffix. - cmd/internal/cmdutil/template_test.go: 12 unit tests covering URL resolution priority and all ResolveTemplateID code paths via httptest. - cmd/export-build/main_test.go: 5 unit tests covering resolveSpec priority chain (flag URL > env var > legacy env). --- .../orchestrator/cmd/export-build/main.go | 284 ++++++++++++++++++ .../cmd/export-build/main_test.go | 102 +++++++ .../cmd/internal/cmdutil/template.go | 24 +- .../cmd/internal/cmdutil/template_test.go | 160 ++++++++++ 4 files changed, 566 insertions(+), 4 deletions(-) create mode 100644 packages/orchestrator/cmd/export-build/main.go create mode 100644 packages/orchestrator/cmd/export-build/main_test.go create mode 100644 packages/orchestrator/cmd/internal/cmdutil/template_test.go diff --git a/packages/orchestrator/cmd/export-build/main.go b/packages/orchestrator/cmd/export-build/main.go new file mode 100644 index 0000000000..aaf1bb506c --- /dev/null +++ b/packages/orchestrator/cmd/export-build/main.go @@ -0,0 +1,284 @@ +//go:build linux + +// export-build exports a template rootfs from E2B object storage as a +// Docker-loadable OCI tar archive, or pushes it directly to a container +// registry. +// +// # Quick start — env-var style (avoids long flag strings on every invocation) +// +// Set these once (e.g. in ~/.bashrc or sourced from the Nomad job env): +// +// export TEMPLATE_STORAGE_URL="s3://dev-template?endpoint=https://tos-s3-cn-shanghai.ivolces.com®ion=cn-shanghai" +// export AWS_ACCESS_KEY_ID=... +// export AWS_SECRET_ACCESS_KEY=... +// export AWS_REGION=cn-shanghai +// export E2B_API_KEY=e2b_... # required when using -template +// export E2B_API_URL=http://localhost:3000 # self-hosted / local dev; defaults to https://api.e2b.dev +// +// Then export a template by alias: +// +// sudo -E export-build -template my-alias -output /tmp/rootfs.tar +// sudo -E export-build -template my-alias -push registry.example.com/img:v1 +// +// # Explicit flag style (no env vars needed) +// +// sudo -E export-build \ +// -build 4f9a9809-5a78-4cad-a56a-c87b06a6facb \ +// -storage "s3://dev-template?endpoint=https://tos-s3-cn-shanghai.ivolces.com®ion=cn-shanghai" \ +// -output /tmp/rootfs.tar \ +// -tag my-alias:latest +// +// # Prerequisites +// +// modprobe nbd max_part=8 # load NBD kernel module before first run +// # (already loaded on orchestrator nodes) +// +// # Storage URL formats +// +// s3://bucket?endpoint=https://...®ion=us-east-1 AWS S3 / S3-compatible +// gs://bucket Google Cloud Storage +// file:///abs/path local directory +// +// Important: S3-compatible stores that do NOT support path-style addressing +// (e.g. Volcengine TOS) must omit s3ForcePathStyle from the URL. +package main + +import ( + "context" + "flag" + "fmt" + "io" + "log" + "os" + "os/exec" + "os/signal" + "path/filepath" + "strings" + "time" + + "github.com/google/go-containerregistry/pkg/authn" + "github.com/google/go-containerregistry/pkg/name" + "github.com/google/go-containerregistry/pkg/v1/empty" + "github.com/google/go-containerregistry/pkg/v1/mutate" + "github.com/google/go-containerregistry/pkg/v1/remote" + "github.com/google/go-containerregistry/pkg/v1/tarball" + "github.com/google/uuid" + + "github.com/e2b-dev/infra/packages/orchestrator/cmd/internal/cmdutil" + "github.com/e2b-dev/infra/packages/orchestrator/pkg/cfg" + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/block" + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/nbd" + "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/nbd/testutils" + "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" + "github.com/e2b-dev/infra/packages/shared/pkg/storage" +) + +func main() { + buildFlag := flag.String("build", "", "build `ID` (UUID); mutually exclusive with -template") + templateFlag := flag.String("template", "", "template alias or ID; resolved to a build ID via the E2B API\n\t(requires E2B_API_KEY; set E2B_API_URL for self-hosted/local dev, or E2B_DOMAIN for self-hosted)") + storageFlag := flag.String("storage", "", + "storage `URL` for the template bucket.\n"+ + "\tFormats: s3://bucket?endpoint=...®ion=..., gs://bucket, file:///path\n"+ + "\tWhen omitted, falls back to TEMPLATE_STORAGE_URL env var, then to the\n"+ + "\tlegacy STORAGE_PROVIDER + TEMPLATE_BUCKET_NAME env vars.\n"+ + "\tAWS credentials are always read from the standard AWS env vars\n"+ + "\t(AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_REGION, etc.).") + outputFlag := flag.String("output", "", "write a docker-loadable tar to `file`; load with: docker load -i ") + pushFlag := flag.String("push", "", "push the image to a registry `ref`, e.g. myrepo/myimage:latest") + tagFlag := flag.String("tag", "", "image `tag` in the tar manifest\n\t(default: :latest when -template is used, otherwise e2b-rootfs:latest)") + flag.Parse() + + if *buildFlag == "" && *templateFlag == "" { + log.Fatal("one of -build or -template is required") + } + if *buildFlag != "" && *templateFlag != "" { + log.Fatal("-build and -template are mutually exclusive") + } + if *outputFlag == "" && *pushFlag == "" { + log.Fatal("one of -output or -push is required") + } + + buildID := *buildFlag + if *templateFlag != "" { + var err error + buildID, err = cmdutil.ResolveTemplateID(*templateFlag) + if err != nil { + log.Fatalf("resolve template %q: %v", *templateFlag, err) + } + fmt.Printf("resolved template %q -> build %s\n", *templateFlag, buildID) + } + + // Default the image tag to the template alias so the loaded image has a + // meaningful name. Falls back to a generic name when -build is used directly. + imageTag := *tagFlag + if imageTag == "" { + if *templateFlag != "" { + imageTag = *templateFlag + ":latest" + } else { + imageTag = "e2b-rootfs:latest" + } + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, os.Interrupt) + go func() { <-sig; cancel() }() + + // NBD cleanup must not share the cancellable ctx: cancelling the context + // while a device is still serving blocks causes a deadlock in the kernel + // NBD driver. A separate background context lets the deferred cleanups in + // run() drain gracefully after the main ctx is cancelled. + nbdCtx := context.Background() + + if err := run(ctx, nbdCtx, buildID, *storageFlag, *outputFlag, *pushFlag, imageTag); err != nil { + log.Fatalf("export failed: %v", err) + } +} + +// resolveSpec resolves the template storage destination in priority order: +// 1. -storage flag as a URL (e.g. s3://bucket?endpoint=...®ion=...) +// 2. -storage flag as a local path (convenience alias for file:// URLs) +// 3. TEMPLATE_STORAGE_URL environment variable +// 4. Legacy STORAGE_PROVIDER + TEMPLATE_BUCKET_NAME environment variables +func resolveSpec(storageFlag string) (storage.Spec, error) { + if storageFlag == "" { + // Delegate to cfg which reads TEMPLATE_STORAGE_URL first, then falls + // back to the legacy STORAGE_PROVIDER / TEMPLATE_BUCKET_NAME vars. + return cfg.TemplateStorage() + } + if strings.Contains(storageFlag, "://") { + return storage.ParseStorageURL(storageFlag) + } + // Plain path -- treat as a local directory (consistent with mount-build-rootfs). + return cmdutil.TemplateSpec(storageFlag) +} + +func run(ctx, nbdCtx context.Context, buildID, storageFlag, outputPath, pushRef, imageTag string) error { + templateSpec, err := resolveSpec(storageFlag) + if err != nil { + return fmt.Errorf("resolve storage: %w", err) + } + fmt.Printf("storage provider: %s\n", templateSpec.Provider) + + fmt.Println("loading rootfs from storage...") + rootfs, rootfsCleanup, err := testutils.TemplateRootfs(ctx, templateSpec, buildID) + defer rootfsCleanup.Run(ctx, 30*time.Second) + if err != nil { + return fmt.Errorf("load rootfs: %w", err) + } + + // COW cache absorbs writes from the NBD layer during mount, keeping the + // stored rootfs immutable (safe to export while sandboxes are running). + cowPath := filepath.Join(os.TempDir(), buildID+"-export-cow-"+uuid.New().String()) + defer os.RemoveAll(cowPath) + + cache, err := block.NewCache( + int64(rootfs.Header().Metadata.Size), + int64(rootfs.Header().Metadata.BlockSize), + cowPath, + false, + ) + if err != nil { + return fmt.Errorf("create COW cache: %w", err) + } + + overlay := block.NewOverlay(rootfs, cache) + defer overlay.Close() + + featureFlags, err := featureflags.NewClient() + if err != nil { + return fmt.Errorf("create feature flags client: %w", err) + } + + fmt.Println("provisioning NBD device...") + devicePath, deviceCleanup, err := nbd.GetNBDDevice(nbdCtx, testutils.NewLoggerOverlay(overlay), featureFlags) + defer deviceCleanup.Run(ctx, 30*time.Second) + if err != nil { + // If this fails with "NBD module not loaded", run: modprobe nbd max_part=8 + return fmt.Errorf("get NBD device: %w", err) + } + fmt.Printf("rootfs on device: %s\n", devicePath) + + mountPath, err := os.MkdirTemp("", buildID+"-export-mount-") + if err != nil { + return fmt.Errorf("create mount dir: %w", err) + } + defer os.RemoveAll(mountPath) + + fmt.Printf("mounting at %s...\n", mountPath) + mountCleanup, err := nbd.MountNBDDevice(devicePath, mountPath) + defer mountCleanup.Run(ctx, 30*time.Second) + if err != nil { + return fmt.Errorf("mount NBD device: %w", err) + } + + fmt.Println("building OCI image layer...") + layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { + pr, pw := io.Pipe() + cmd := exec.CommandContext(ctx, "tar", + "-C", mountPath, + "--one-file-system", // do not cross bind-mount boundaries inside the rootfs + "--numeric-owner", // preserve UID/GID numerically -- no host passwd lookup + "-c", + ".", + ) + cmd.Stdout = pw + cmd.Stderr = os.Stderr + if err := cmd.Start(); err != nil { + pw.Close() + pr.Close() + return nil, fmt.Errorf("start tar: %w", err) + } + go func() { pw.CloseWithError(cmd.Wait()) }() + return pr, nil + }) + if err != nil { + return fmt.Errorf("create OCI layer: %w", err) + } + + img, err := mutate.AppendLayers(empty.Image, layer) + if err != nil { + return fmt.Errorf("append layer: %w", err) + } + + cfgFile, err := img.ConfigFile() + if err != nil { + return fmt.Errorf("get image config: %w", err) + } + cfgFile.Architecture = "amd64" + cfgFile.OS = "linux" + img, err = mutate.ConfigFile(img, cfgFile) + if err != nil { + return fmt.Errorf("set image config: %w", err) + } + + if outputPath != "" { + fmt.Printf("writing tar to %s (tag: %s)...\n", outputPath, imageTag) + tag, err := name.NewTag(imageTag) + if err != nil { + return fmt.Errorf("parse image tag %q: %w", imageTag, err) + } + if err := tarball.WriteToFile(outputPath, tag, img); err != nil { + return fmt.Errorf("write tar: %w", err) + } + fmt.Printf("done -- load with: docker load -i %s\n", outputPath) + } + + if pushRef != "" { + fmt.Printf("pushing to %s...\n", pushRef) + ref, err := name.ParseReference(pushRef) + if err != nil { + return fmt.Errorf("parse push ref %q: %w", pushRef, err) + } + // Credentials sourced from the standard keychain: ~/.docker/config.json, + // credential helpers (docker-credential-*), or DOCKER_CONFIG env var. + if err := remote.Write(ref, img, remote.WithAuthFromKeychain(authn.DefaultKeychain)); err != nil { + return fmt.Errorf("push image: %w", err) + } + fmt.Printf("done -- pushed to %s\n", pushRef) + } + + return nil +} diff --git a/packages/orchestrator/cmd/export-build/main_test.go b/packages/orchestrator/cmd/export-build/main_test.go new file mode 100644 index 0000000000..eca54d2306 --- /dev/null +++ b/packages/orchestrator/cmd/export-build/main_test.go @@ -0,0 +1,102 @@ +//go:build linux + +//nolint:paralleltest // env-driven tests use t.Setenv +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/e2b-dev/infra/packages/shared/pkg/storage" +) + +func TestResolveSpec_FlagURL(t *testing.T) { + tests := []struct { + name string + storageFlag string + wantProvider storage.Provider + wantBucket string + wantEndpoint string + wantPathStyle bool + }{ + { + name: "s3 url with endpoint", + storageFlag: "s3://my-bucket?endpoint=http://minio:9000&s3ForcePathStyle=true®ion=us-east-1", + wantProvider: storage.AWSStorageProvider, + wantBucket: "my-bucket", + wantEndpoint: "http://minio:9000", + wantPathStyle: true, + }, + { + name: "s3 url without path style (TOS-compatible)", + storageFlag: "s3://tos-bucket?endpoint=https://tos-s3-cn-shanghai.ivolces.com®ion=cn-shanghai", + wantProvider: storage.AWSStorageProvider, + wantBucket: "tos-bucket", + wantEndpoint: "https://tos-s3-cn-shanghai.ivolces.com", + wantPathStyle: false, + }, + { + name: "gcs url", + storageFlag: "gs://my-gcs-bucket", + wantProvider: storage.GCPStorageProvider, + wantBucket: "my-gcs-bucket", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Env vars must not interfere when a flag URL is provided. + t.Setenv("TEMPLATE_STORAGE_URL", "gs://should-not-be-used") + + spec, err := resolveSpec(tt.storageFlag) + require.NoError(t, err) + assert.Equal(t, tt.wantProvider, spec.Provider) + assert.Equal(t, tt.wantBucket, spec.Bucket) + assert.Equal(t, tt.wantEndpoint, spec.Endpoint) + assert.Equal(t, tt.wantPathStyle, spec.UsePathStyle) + }) + } +} + +func TestResolveSpec_FlagLocalPath(t *testing.T) { + // A plain path (no ://) is treated as a local directory. + spec, err := resolveSpec(".local-build") + require.NoError(t, err) + assert.Equal(t, storage.LocalStorageProvider, spec.Provider) +} + +func TestResolveSpec_EnvVar(t *testing.T) { + // When no flag is given, TEMPLATE_STORAGE_URL is used. + t.Setenv("TEMPLATE_STORAGE_URL", "s3://env-bucket?endpoint=http://minio:9000&s3ForcePathStyle=true®ion=us-east-1") + t.Setenv("STORAGE_PROVIDER", "") + + spec, err := resolveSpec("") + require.NoError(t, err) + assert.Equal(t, storage.AWSStorageProvider, spec.Provider) + assert.Equal(t, "env-bucket", spec.Bucket) +} + +func TestResolveSpec_FlagTakesPrecedenceOverEnv(t *testing.T) { + // Explicit -storage flag wins over TEMPLATE_STORAGE_URL env var. + t.Setenv("TEMPLATE_STORAGE_URL", "s3://env-bucket") + + spec, err := resolveSpec("gs://flag-bucket") + require.NoError(t, err) + assert.Equal(t, storage.GCPStorageProvider, spec.Provider) + assert.Equal(t, "flag-bucket", spec.Bucket) +} + +func TestResolveSpec_LegacyEnvFallback(t *testing.T) { + // When both TEMPLATE_STORAGE_URL and -storage are absent, the legacy env + // vars (STORAGE_PROVIDER + TEMPLATE_BUCKET_NAME) are used. + t.Setenv("TEMPLATE_STORAGE_URL", "") + t.Setenv("STORAGE_PROVIDER", "GCPBucket") + t.Setenv("TEMPLATE_BUCKET_NAME", "legacy-bucket") + + spec, err := resolveSpec("") + require.NoError(t, err) + assert.Equal(t, storage.GCPStorageProvider, spec.Provider) + assert.Equal(t, "legacy-bucket", spec.Bucket) +} diff --git a/packages/orchestrator/cmd/internal/cmdutil/template.go b/packages/orchestrator/cmd/internal/cmdutil/template.go index 21c8958dd7..1eb6f2acd6 100644 --- a/packages/orchestrator/cmd/internal/cmdutil/template.go +++ b/packages/orchestrator/cmd/internal/cmdutil/template.go @@ -24,16 +24,18 @@ type templateInfo struct { // ResolveTemplateID fetches the build ID for a template from the E2B API. // Input can be a template ID, alias, or full name (e.g. "e2b/base"). +// +// API endpoint resolution order: +// 1. E2B_API_URL — full base URL, e.g. http://localhost:3000 (self-hosted / local dev) +// 2. E2B_DOMAIN — domain suffix, produces https://api. +// 3. default — https://api.e2b.dev func ResolveTemplateID(input string) (string, error) { apiKey := os.Getenv("E2B_API_KEY") if apiKey == "" { return "", errors.New("E2B_API_KEY environment variable required for -template flag") } - apiURL := "https://api.e2b.dev/templates" - if domain := os.Getenv("E2B_DOMAIN"); domain != "" { - apiURL = fmt.Sprintf("https://api.%s/templates", domain) - } + apiURL := resolveAPIURL() + "/templates" ctx := context.Background() req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) @@ -81,3 +83,17 @@ func ResolveTemplateID(input string) (string, error) { return match.BuildID, nil } + +// resolveAPIURL returns the E2B API base URL (no trailing slash). +// Priority: E2B_API_URL > E2B_DOMAIN > default (api.e2b.dev). +func resolveAPIURL() string { + // E2B_API_URL accepts a full base URL — useful for self-hosted deployments + // and local dev where the API does not sit behind a domain (e.g. http://localhost:3000). + if u := strings.TrimRight(os.Getenv("E2B_API_URL"), "/"); u != "" { + return u + } + if domain := os.Getenv("E2B_DOMAIN"); domain != "" { + return fmt.Sprintf("https://api.%s", domain) + } + return "https://api.e2b.dev" +} diff --git a/packages/orchestrator/cmd/internal/cmdutil/template_test.go b/packages/orchestrator/cmd/internal/cmdutil/template_test.go new file mode 100644 index 0000000000..196ea81d05 --- /dev/null +++ b/packages/orchestrator/cmd/internal/cmdutil/template_test.go @@ -0,0 +1,160 @@ +//nolint:paralleltest // env-driven tests use t.Setenv +package cmdutil + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// --- resolveAPIURL --- + +func TestResolveAPIURL_Default(t *testing.T) { + t.Setenv("E2B_API_URL", "") + t.Setenv("E2B_DOMAIN", "") + assert.Equal(t, "https://api.e2b.dev", resolveAPIURL()) +} + +func TestResolveAPIURL_Domain(t *testing.T) { + t.Setenv("E2B_API_URL", "") + t.Setenv("E2B_DOMAIN", "my-e2b.internal") + assert.Equal(t, "https://api.my-e2b.internal", resolveAPIURL()) +} + +func TestResolveAPIURL_FullURL(t *testing.T) { + // E2B_API_URL accepts any base URL, including http:// for local dev. + t.Setenv("E2B_API_URL", "http://localhost:3000") + t.Setenv("E2B_DOMAIN", "ignored.example.com") // must be ignored + assert.Equal(t, "http://localhost:3000", resolveAPIURL()) +} + +func TestResolveAPIURL_TrailingSlashStripped(t *testing.T) { + t.Setenv("E2B_API_URL", "http://localhost:3000/") + t.Setenv("E2B_DOMAIN", "") + assert.Equal(t, "http://localhost:3000", resolveAPIURL()) +} + +// --- ResolveTemplateID --- + +// mockTemplateServer starts an httptest server that returns the given templates +// and checks that each request carries the expected API key. +func mockTemplateServer(t *testing.T, apiKey string, templates []templateInfo) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("X-API-Key") != apiKey { + w.WriteHeader(http.StatusUnauthorized) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(templates) + })) +} + +func TestResolveTemplateID_ByTemplateID(t *testing.T) { + srv := mockTemplateServer(t, "test-key", []templateInfo{ + {TemplateID: "tpl-abc", BuildID: "build-uuid-1", Aliases: []string{"my-alias"}}, + }) + defer srv.Close() + + t.Setenv("E2B_API_KEY", "test-key") + t.Setenv("E2B_API_URL", srv.URL) + + id, err := ResolveTemplateID("tpl-abc") + require.NoError(t, err) + assert.Equal(t, "build-uuid-1", id) +} + +func TestResolveTemplateID_ByAlias(t *testing.T) { + srv := mockTemplateServer(t, "test-key", []templateInfo{ + {TemplateID: "tpl-abc", BuildID: "build-uuid-1", Aliases: []string{"my-alias", "other-alias"}}, + }) + defer srv.Close() + + t.Setenv("E2B_API_KEY", "test-key") + t.Setenv("E2B_API_URL", srv.URL) + + id, err := ResolveTemplateID("other-alias") + require.NoError(t, err) + assert.Equal(t, "build-uuid-1", id) +} + +func TestResolveTemplateID_ByName(t *testing.T) { + srv := mockTemplateServer(t, "test-key", []templateInfo{ + {TemplateID: "tpl-abc", BuildID: "build-uuid-1", Names: []string{"org/my-image"}}, + }) + defer srv.Close() + + t.Setenv("E2B_API_KEY", "test-key") + t.Setenv("E2B_API_URL", srv.URL) + + id, err := ResolveTemplateID("org/my-image") + require.NoError(t, err) + assert.Equal(t, "build-uuid-1", id) +} + +func TestResolveTemplateID_NotFound(t *testing.T) { + srv := mockTemplateServer(t, "test-key", []templateInfo{ + {TemplateID: "tpl-abc", BuildID: "build-uuid-1", Aliases: []string{"existing"}}, + }) + defer srv.Close() + + t.Setenv("E2B_API_KEY", "test-key") + t.Setenv("E2B_API_URL", srv.URL) + + _, err := ResolveTemplateID("nonexistent") + require.ErrorContains(t, err, "not found") + // Error message lists available aliases to help users. + require.ErrorContains(t, err, "existing") +} + +func TestResolveTemplateID_NilUUID(t *testing.T) { + // A template whose latest build failed has buildID == nilUUID. + srv := mockTemplateServer(t, "test-key", []templateInfo{ + {TemplateID: "broken", BuildID: nilUUID, Aliases: []string{"broken-alias"}}, + }) + defer srv.Close() + + t.Setenv("E2B_API_KEY", "test-key") + t.Setenv("E2B_API_URL", srv.URL) + + _, err := ResolveTemplateID("broken") + require.ErrorContains(t, err, "no successful build") +} + +func TestResolveTemplateID_EmptyBuildID(t *testing.T) { + srv := mockTemplateServer(t, "test-key", []templateInfo{ + {TemplateID: "pending", BuildID: "", Aliases: []string{"pending-alias"}}, + }) + defer srv.Close() + + t.Setenv("E2B_API_KEY", "test-key") + t.Setenv("E2B_API_URL", srv.URL) + + _, err := ResolveTemplateID("pending") + require.ErrorContains(t, err, "no successful build") +} + +func TestResolveTemplateID_MissingAPIKey(t *testing.T) { + t.Setenv("E2B_API_KEY", "") + + _, err := ResolveTemplateID("any-template") + require.ErrorContains(t, err, "E2B_API_KEY") +} + +func TestResolveTemplateID_APIError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "Invalid host", http.StatusBadRequest) + })) + defer srv.Close() + + t.Setenv("E2B_API_KEY", "test-key") + t.Setenv("E2B_API_URL", srv.URL) + + _, err := ResolveTemplateID("any-template") + require.ErrorContains(t, err, "400") + require.ErrorContains(t, err, "Invalid host") +} From 6e8232d6415e6ea1db25d71cbd4071a4bf4452e4 Mon Sep 17 00:00:00 2001 From: AdaAibaby Date: Wed, 15 Jul 2026 18:55:57 +0800 Subject: [PATCH 2/4] fix(export-build): address code review feedback - cleanup: check error before defer, run cleanup with nbdCtx on early return so cleanups are not skipped on failure. Note: all three helpers (TemplateRootfs, GetNBDDevice, MountNBDDevice) always return a non-nil Cleaner, and Cleaner.Run internally calls context.WithoutCancel so the cancel signal does not affect cleanup; the pattern change is for clarity and defensive correctness. - tag: normalise default image tag to lowercase (strings.ToLower) to satisfy Docker repository naming rules when the template alias contains uppercase letters. - template API: add 15s timeout to the template-resolution HTTP request to prevent indefinite hangs when the API is slow or unreachable. --- packages/orchestrator/cmd/export-build/main.go | 15 +++++++++------ .../orchestrator/cmd/internal/cmdutil/template.go | 5 ++++- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/packages/orchestrator/cmd/export-build/main.go b/packages/orchestrator/cmd/export-build/main.go index aaf1bb506c..4aa89f9d83 100644 --- a/packages/orchestrator/cmd/export-build/main.go +++ b/packages/orchestrator/cmd/export-build/main.go @@ -108,12 +108,12 @@ func main() { fmt.Printf("resolved template %q -> build %s\n", *templateFlag, buildID) } - // Default the image tag to the template alias so the loaded image has a - // meaningful name. Falls back to a generic name when -build is used directly. + // Docker repository names must be lowercase; template aliases are not + // guaranteed to be, so normalise the default tag. imageTag := *tagFlag if imageTag == "" { if *templateFlag != "" { - imageTag = *templateFlag + ":latest" + imageTag = strings.ToLower(*templateFlag) + ":latest" } else { imageTag = "e2b-rootfs:latest" } @@ -164,10 +164,11 @@ func run(ctx, nbdCtx context.Context, buildID, storageFlag, outputPath, pushRef, fmt.Println("loading rootfs from storage...") rootfs, rootfsCleanup, err := testutils.TemplateRootfs(ctx, templateSpec, buildID) - defer rootfsCleanup.Run(ctx, 30*time.Second) if err != nil { + rootfsCleanup.Run(nbdCtx, 30*time.Second) return fmt.Errorf("load rootfs: %w", err) } + defer rootfsCleanup.Run(nbdCtx, 30*time.Second) // COW cache absorbs writes from the NBD layer during mount, keeping the // stored rootfs immutable (safe to export while sandboxes are running). @@ -194,11 +195,12 @@ func run(ctx, nbdCtx context.Context, buildID, storageFlag, outputPath, pushRef, fmt.Println("provisioning NBD device...") devicePath, deviceCleanup, err := nbd.GetNBDDevice(nbdCtx, testutils.NewLoggerOverlay(overlay), featureFlags) - defer deviceCleanup.Run(ctx, 30*time.Second) if err != nil { + deviceCleanup.Run(nbdCtx, 30*time.Second) // If this fails with "NBD module not loaded", run: modprobe nbd max_part=8 return fmt.Errorf("get NBD device: %w", err) } + defer deviceCleanup.Run(nbdCtx, 30*time.Second) fmt.Printf("rootfs on device: %s\n", devicePath) mountPath, err := os.MkdirTemp("", buildID+"-export-mount-") @@ -209,10 +211,11 @@ func run(ctx, nbdCtx context.Context, buildID, storageFlag, outputPath, pushRef, fmt.Printf("mounting at %s...\n", mountPath) mountCleanup, err := nbd.MountNBDDevice(devicePath, mountPath) - defer mountCleanup.Run(ctx, 30*time.Second) if err != nil { + mountCleanup.Run(nbdCtx, 30*time.Second) return fmt.Errorf("mount NBD device: %w", err) } + defer mountCleanup.Run(nbdCtx, 30*time.Second) fmt.Println("building OCI image layer...") layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { diff --git a/packages/orchestrator/cmd/internal/cmdutil/template.go b/packages/orchestrator/cmd/internal/cmdutil/template.go index 1eb6f2acd6..45cd0da124 100644 --- a/packages/orchestrator/cmd/internal/cmdutil/template.go +++ b/packages/orchestrator/cmd/internal/cmdutil/template.go @@ -10,6 +10,7 @@ import ( "os" "slices" "strings" + "time" ) const nilUUID = "00000000-0000-0000-0000-000000000000" @@ -37,7 +38,9 @@ func ResolveTemplateID(input string) (string, error) { apiURL := resolveAPIURL() + "/templates" - ctx := context.Background() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) From 4e86d63f582ed535600ad26ab5772eb87a029cfa Mon Sep 17 00:00:00 2001 From: AdaAibaby Date: Wed, 15 Jul 2026 19:24:10 +0800 Subject: [PATCH 3/4] export-build: preserve xattrs in OCI layer; use utils.TargetArch() for OCI platform --- packages/orchestrator/cmd/export-build/main.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/orchestrator/cmd/export-build/main.go b/packages/orchestrator/cmd/export-build/main.go index 4aa89f9d83..b4db30168f 100644 --- a/packages/orchestrator/cmd/export-build/main.go +++ b/packages/orchestrator/cmd/export-build/main.go @@ -71,6 +71,7 @@ import ( "github.com/e2b-dev/infra/packages/orchestrator/pkg/sandbox/nbd/testutils" "github.com/e2b-dev/infra/packages/shared/pkg/featureflags" "github.com/e2b-dev/infra/packages/shared/pkg/storage" + "github.com/e2b-dev/infra/packages/shared/pkg/utils" ) func main() { @@ -224,6 +225,8 @@ func run(ctx, nbdCtx context.Context, buildID, storageFlag, outputPath, pushRef, "-C", mountPath, "--one-file-system", // do not cross bind-mount boundaries inside the rootfs "--numeric-owner", // preserve UID/GID numerically -- no host passwd lookup + "--xattrs", // preserve extended attributes (e.g. security.capability) + "--xattrs-include=*", // include all xattr namespaces; stored as PAX headers in the OCI layer "-c", ".", ) @@ -250,7 +253,7 @@ func run(ctx, nbdCtx context.Context, buildID, storageFlag, outputPath, pushRef, if err != nil { return fmt.Errorf("get image config: %w", err) } - cfgFile.Architecture = "amd64" + cfgFile.Architecture = utils.TargetArch() // respects TARGET_ARCH env var; defaults to host arch cfgFile.OS = "linux" img, err = mutate.ConfigFile(img, cfgFile) if err != nil { From b8a7175f0d1b405497f446d5d35cd13792f2f9a2 Mon Sep 17 00:00:00 2001 From: AdaAibaby Date: Wed, 15 Jul 2026 19:38:25 +0800 Subject: [PATCH 4/4] export-build: buffer layer to temp file to fix push ContentLength mismatch --- .../orchestrator/cmd/export-build/main.go | 56 +++++++++++-------- 1 file changed, 34 insertions(+), 22 deletions(-) diff --git a/packages/orchestrator/cmd/export-build/main.go b/packages/orchestrator/cmd/export-build/main.go index b4db30168f..7c96552fa3 100644 --- a/packages/orchestrator/cmd/export-build/main.go +++ b/packages/orchestrator/cmd/export-build/main.go @@ -47,7 +47,6 @@ import ( "context" "flag" "fmt" - "io" "log" "os" "os/exec" @@ -218,28 +217,41 @@ func run(ctx, nbdCtx context.Context, buildID, storageFlag, outputPath, pushRef, } defer mountCleanup.Run(nbdCtx, 30*time.Second) + // Archive to a temp file before building the OCI layer. go-containerregistry + // calls the layer opener twice (digest computation + actual upload) and sends + // Content-Length from the first pass. A live tar pipe can produce slightly + // different bytes on each run (xattr PAX headers vary when filesystem metadata + // is touched during the first read), causing an HTTP ContentLength mismatch. + // Writing once to a file guarantees both passes read identical bytes. + layerFile, err := os.CreateTemp("", buildID+"-layer-*.tar") + if err != nil { + return fmt.Errorf("create layer temp file: %w", err) + } + layerPath := layerFile.Name() + defer os.Remove(layerPath) + + fmt.Println("archiving filesystem...") + tarCmd := exec.CommandContext(ctx, "tar", + "-C", mountPath, + "--one-file-system", // do not cross bind-mount boundaries inside the rootfs + "--numeric-owner", // preserve UID/GID numerically -- no host passwd lookup + "--xattrs", // preserve extended attributes (e.g. security.capability) + "--xattrs-include=*", // include all xattr namespaces; stored as PAX headers in the OCI layer + "-c", + ".", + ) + tarCmd.Stdout = layerFile + tarCmd.Stderr = os.Stderr + if err := tarCmd.Run(); err != nil { + layerFile.Close() + return fmt.Errorf("archive rootfs: %w", err) + } + if err := layerFile.Close(); err != nil { + return fmt.Errorf("flush layer file: %w", err) + } + fmt.Println("building OCI image layer...") - layer, err := tarball.LayerFromOpener(func() (io.ReadCloser, error) { - pr, pw := io.Pipe() - cmd := exec.CommandContext(ctx, "tar", - "-C", mountPath, - "--one-file-system", // do not cross bind-mount boundaries inside the rootfs - "--numeric-owner", // preserve UID/GID numerically -- no host passwd lookup - "--xattrs", // preserve extended attributes (e.g. security.capability) - "--xattrs-include=*", // include all xattr namespaces; stored as PAX headers in the OCI layer - "-c", - ".", - ) - cmd.Stdout = pw - cmd.Stderr = os.Stderr - if err := cmd.Start(); err != nil { - pw.Close() - pr.Close() - return nil, fmt.Errorf("start tar: %w", err) - } - go func() { pw.CloseWithError(cmd.Wait()) }() - return pr, nil - }) + layer, err := tarball.LayerFromFile(layerPath) if err != nil { return fmt.Errorf("create OCI layer: %w", err) }