diff --git a/packages/orchestrator/cmd/export-build/main.go b/packages/orchestrator/cmd/export-build/main.go new file mode 100644 index 0000000000..7c96552fa3 --- /dev/null +++ b/packages/orchestrator/cmd/export-build/main.go @@ -0,0 +1,302 @@ +//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" + "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" + "github.com/e2b-dev/infra/packages/shared/pkg/utils" +) + +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) + } + + // 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 = strings.ToLower(*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) + 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). + 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) + 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-") + 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) + if err != nil { + mountCleanup.Run(nbdCtx, 30*time.Second) + return fmt.Errorf("mount NBD device: %w", err) + } + 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.LayerFromFile(layerPath) + 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 = utils.TargetArch() // respects TARGET_ARCH env var; defaults to host arch + 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..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" @@ -24,18 +25,22 @@ 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, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() - ctx := context.Background() req, err := http.NewRequestWithContext(ctx, http.MethodGet, apiURL, nil) if err != nil { return "", fmt.Errorf("failed to create request: %w", err) @@ -81,3 +86,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") +}