-
Notifications
You must be signed in to change notification settings - Fork 367
feat(orchestrator): add export-build command (#3272) #3291
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
AdaAibaby
wants to merge
6
commits into
e2b-dev:main
Choose a base branch
from
AdaAibaby:feat/export-build-rootfs-as-docker-image
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
+588
−5
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
4329393
feat(orchestrator): add export-build command to export template rootf…
adababys 45c92a4
Merge branch 'main' into feat/export-build-rootfs-as-docker-image
AdaAibaby 6e8232d
fix(export-build): address code review feedback
adababys 31d9749
Merge branch 'main' into feat/export-build-rootfs-as-docker-image
AdaAibaby 4e86d63
export-build: preserve xattrs in OCI layer; use utils.TargetArch() fo…
adababys b8a7175
export-build: buffer layer to temp file to fix push ContentLength mis…
adababys File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <file>") | ||
| 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: <template-alias>: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) | ||
| } | ||
|
AdaAibaby marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
|
AdaAibaby marked this conversation as resolved.
|
||
| 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) | ||
| } | ||
|
AdaAibaby marked this conversation as resolved.
|
||
| 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 | ||
| } | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.