Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Both client libraries are pre-1.0, and they have separate versioning.
## Unreleased

- Upgraded the internal handling of Sandbox exec to use the new command router interface, which brings greatly improved performance and reliability for exec operations.
- Added support for experimental features for snapshotting a single directory, and mounting the resulting Image at a specific directory in the Sandbox filesystem.

## modal-js/v0.6.0, modal-go/v0.6.0

Expand Down
139 changes: 139 additions & 0 deletions modal-go/examples/sandbox-image-mount/main.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
// This example shows how to mount Images in the Sandbox filesystem and take snapshots
// of them.
//
// The feature is still experimental in the sense that the API is subject to change.
//
// High level, it allows you to:
// - Mount any Modal Image at a specific directory within the Sandbox filesystem.
// - Take a snapshot of that directory, which will create a new Modal Image with
// the updated contents of the directory.
//
// You can only snapshot directories that have previously been mounted using
// `Sandbox.ExperimentalMountImage`. If you want to mount an empty directory,
// you can pass nil as the image parameter.
//
// For exmaple, you can use this to mount user specific dependencies into a running
// Sandbox, that is started with a base Image with shared system dependencies. This
// way, you can update system dependencies and user projects independently.

package main

import (
"context"
"fmt"
"io"
"log"

"github.com/modal-labs/libmodal/modal-go"
)

func main() {
ctx := context.Background()
mc, err := modal.NewClient()
if err != nil {
log.Fatalf("Failed to create client: %v", err)
}

app, err := mc.Apps.FromName(ctx, "libmodal-example", &modal.AppFromNameParams{CreateIfMissing: true})
if err != nil {
log.Fatalf("Failed to get or create App: %v", err)
}

// The base Image you use for the Sandbox must have a /usr/bin/mount binary.
baseImage := mc.Images.FromRegistry("debian:12-slim", nil).DockerfileCommands([]string{
"RUN apt-get update && apt-get install -y git",
}, nil)

sb, err := mc.Sandboxes.Create(ctx, app, baseImage, nil)
if err != nil {
log.Fatalf("Failed to create Sandbox: %v", err)
}
defer func() {
if err := sb.Terminate(context.Background()); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb.SandboxID, err)
}
}()
fmt.Printf("Started first Sandbox: %s\n", sb.SandboxID)

// You must mount an Image at a directory in the Sandbox filesystem before you
// can snapshot it. You can pass nil as the image parameter to mount an
// empty directory.
//
// The target directory must exist before you can mount it:
mkdirProc, err := sb.Exec(ctx, []string{"mkdir", "-p", "/repo"}, nil)
if err != nil {
log.Fatalf("Failed to exec mkdir: %v", err)
}
if exitCode, err := mkdirProc.Wait(ctx); err != nil || exitCode != 0 {
log.Fatalf("Failed to wait for mkdir: exit code: %d, err: %v", exitCode, err)
}
if err := sb.ExperimentalMountImage(ctx, "/repo", nil); err != nil {
log.Fatalf("Failed to mount image: %v", err)
}

gitClone, err := sb.Exec(ctx, []string{
"git",
"clone",
"https://github.com/modal-labs/libmodal.git",
"/repo",
}, nil)
if err != nil {
log.Fatalf("Failed to exec git clone: %v", err)
}
if exitCode, err := gitClone.Wait(ctx); err != nil || exitCode != 0 {
log.Fatalf("Failed to wait for git clone: exit code: %d, err: %v", exitCode, err)
}

repoSnapshot, err := sb.ExperimentalSnapshotDirectory(ctx, "/repo")
if err != nil {
log.Fatalf("Failed to snapshot directory: %v", err)
}
fmt.Printf("Took a snapshot of the /repo directory, Image ID: %s\n", repoSnapshot.ImageID)

if err := sb.Terminate(ctx); err != nil {
log.Fatalf("Failed to terminate Sandbox: %v", err)
}

// Start a new Sandbox, and mount the repo directory:
sb2, err := mc.Sandboxes.Create(ctx, app, baseImage, nil)
if err != nil {
log.Fatalf("Failed to create second Sandbox: %v", err)
}
defer func() {
if err := sb2.Terminate(context.Background()); err != nil {
log.Fatalf("Failed to terminate Sandbox %s: %v", sb2.SandboxID, err)
}
}()
fmt.Printf("Started second Sandbox: %s\n", sb2.SandboxID)

mkdirProc2, err := sb2.Exec(ctx, []string{"mkdir", "-p", "/repo"}, nil)
if err != nil {
log.Fatalf("Failed to exec mkdir in sb2: %v", err)
}
if exitCode, err := mkdirProc2.Wait(ctx); err != nil || exitCode != 0 {
log.Fatalf("Failed to wait for mkdir in sb2: exit code: %d, err: %v", exitCode, err)
}
if err := sb2.ExperimentalMountImage(ctx, "/repo", repoSnapshot); err != nil {
log.Fatalf("Failed to mount snapshot in sb2: %v", err)
}

repoLs, err := sb2.Exec(ctx, []string{"ls", "/repo"}, nil)
if err != nil {
log.Fatalf("Failed to exec ls: %v", err)
}
if exitCode, err := repoLs.Wait(ctx); err != nil || exitCode != 0 {
log.Fatalf("Failed to wait for ls: exit code: %d, err: %v", exitCode, err)
}
output, err := io.ReadAll(repoLs.Stdout)
if err != nil {
log.Fatalf("Failed to read stdout: %v", err)
}
fmt.Printf("Contents of /repo directory in new Sandbox sb2:\n%s", output)

if err := sb2.Terminate(ctx); err != nil {
log.Fatalf("Failed to terminate sb2: %v", err)
}
if err := mc.Images.Delete(ctx, repoSnapshot.ImageID, nil); err != nil {
log.Fatalf("Failed to delete snapshot image: %v", err)
}
}
62 changes: 62 additions & 0 deletions modal-go/sandbox.go
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,68 @@ func (sb *Sandbox) SnapshotFilesystem(ctx context.Context, timeout time.Duration
return &Image{ImageID: resp.GetImageId(), client: sb.client}, nil
}

// ExperimentalMountImage mounts an Image at a path in the Sandbox filesystem.
//
// It's experimental in the sense that the API is subject to change.
//
// If image is nil, mounts an empty directory.
func (sb *Sandbox) ExperimentalMountImage(ctx context.Context, path string, image *Image) error {
if err := sb.ensureTaskID(ctx); err != nil {
return err
}

crClient, err := sb.getOrCreateCommandRouterClient(ctx, sb.taskID)
if err != nil {
return err
}

imageID := ""
if image != nil {
if image.ImageID == "" {
return InvalidError{Exception: "Image must be built before mounting. Call `image.Build(app)` first."}
}
imageID = image.ImageID
}

request := pb.TaskMountDirectoryRequest_builder{
TaskId: sb.taskID,
Path: []byte(path),
ImageId: imageID,
}.Build()

return crClient.MountDirectory(ctx, request)
}

// ExperimentalSnapshotDirectory snapshots local changes to a previously mounted Image into a new Image.
//
// It's experimental in the sense that the API is subject to change.
func (sb *Sandbox) ExperimentalSnapshotDirectory(ctx context.Context, path string) (*Image, error) {
if err := sb.ensureTaskID(ctx); err != nil {
return nil, err
}

crClient, err := sb.getOrCreateCommandRouterClient(ctx, sb.taskID)
if err != nil {
return nil, err
}

request := pb.TaskSnapshotDirectoryRequest_builder{
TaskId: sb.taskID,
Path: []byte(path),
}.Build()

response, err := crClient.SnapshotDirectory(ctx, request)
if err != nil {
return nil, err
}

if response.GetImageId() == "" {
return nil, ExecutionError{Exception: "Sandbox snapshot directory response missing `imageId`"}
}

return &Image{ImageID: response.GetImageId(), client: sb.client}, nil
}

// Poll checks if the Sandbox has finished running.
// Returns nil if the Sandbox is still running, else returns the exit code.
func (sb *Sandbox) Poll(ctx context.Context) (*int, error) {
Expand Down
177 changes: 177 additions & 0 deletions modal-go/test/sandbox_mount_image_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
package test

import (
"context"
"io"
"testing"
"time"

"github.com/modal-labs/libmodal/modal-go"
"github.com/onsi/gomega"
)

func TestSandboxMountDirectoryEmpty(t *testing.T) {
t.Parallel()
g := gomega.NewWithT(t)
ctx := context.Background()
tc := newTestClient(t)

app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
g.Expect(err).ShouldNot(gomega.HaveOccurred())

image := tc.Images.FromRegistry("debian:12-slim", nil)

sb, err := tc.Sandboxes.Create(ctx, app, image, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
defer terminateSandbox(g, sb)

mkdirProc, err := sb.Exec(ctx, []string{"mkdir", "-p", "/mnt/empty"}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
_, err = mkdirProc.Wait(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

err = sb.ExperimentalMountImage(ctx, "/mnt/empty", nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

dirCheck, err := sb.Exec(ctx, []string{"test", "-d", "/mnt/empty"}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
exitCode, err := dirCheck.Wait(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
g.Expect(exitCode).To(gomega.Equal(0))
}

func TestSandboxMountDirectoryWithImage(t *testing.T) {
t.Parallel()
g := gomega.NewWithT(t)
ctx := context.Background()
tc := newTestClient(t)

app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
g.Expect(err).ShouldNot(gomega.HaveOccurred())

baseImage := tc.Images.FromRegistry("debian:12-slim", nil)

sb1, err := tc.Sandboxes.Create(ctx, app, baseImage, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
defer terminateSandbox(g, sb1)

echoProc, err := sb1.Exec(ctx, []string{
"sh",
"-c",
"echo -n 'mounted content' > /tmp/test.txt",
}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
_, err = echoProc.Wait(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

mountImage, err := sb1.SnapshotFilesystem(ctx, 55*time.Second)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we going to advertise that you can snapshot the whole filesystem and then mounting it into /mnt/data in another sandbox?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah I think it's alright? Used it mostly as a convenient way to make an image not from a directory snapshot. But this test perhaps doesn't add much in addition to the next one, so could skip it entirely I suppose.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think it's okay to test as long as this is a supported feature.

g.Expect(err).ShouldNot(gomega.HaveOccurred())
g.Expect(mountImage.ImageID).To(gomega.MatchRegexp(`^im-`))

err = sb1.Terminate(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

sb2, err := tc.Sandboxes.Create(ctx, app, baseImage, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
defer terminateSandbox(g, sb2)

mkdirProc, err := sb2.Exec(ctx, []string{"mkdir", "-p", "/mnt/data"}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
_, err = mkdirProc.Wait(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

err = sb2.ExperimentalMountImage(ctx, "/mnt/data", mountImage)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

catProc, err := sb2.Exec(ctx, []string{"cat", "/mnt/data/tmp/test.txt"}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
output, err := io.ReadAll(catProc.Stdout)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
g.Expect(string(output)).To(gomega.Equal("mounted content"))
}

func TestSandboxSnapshotDirectory(t *testing.T) {
t.Parallel()
g := gomega.NewWithT(t)
ctx := context.Background()
tc := newTestClient(t)

app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
g.Expect(err).ShouldNot(gomega.HaveOccurred())

baseImage := tc.Images.FromRegistry("debian:12-slim", nil)

sb1, err := tc.Sandboxes.Create(ctx, app, baseImage, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
defer terminateSandbox(g, sb1)

mkdirProc, err := sb1.Exec(ctx, []string{"mkdir", "-p", "/mnt/data"}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
_, err = mkdirProc.Wait(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

err = sb1.ExperimentalMountImage(ctx, "/mnt/data", nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

echoProc, err := sb1.Exec(ctx, []string{
"sh",
"-c",
"echo -n 'snapshot test content' > /mnt/data/snapshot.txt",
}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
_, err = echoProc.Wait(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

snapshotImage, err := sb1.ExperimentalSnapshotDirectory(ctx, "/mnt/data")
g.Expect(err).ShouldNot(gomega.HaveOccurred())
g.Expect(snapshotImage.ImageID).To(gomega.MatchRegexp(`^im-`))

err = sb1.Terminate(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

sb2, err := tc.Sandboxes.Create(ctx, app, baseImage, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
defer terminateSandbox(g, sb2)

mkdirProc2, err := sb2.Exec(ctx, []string{"mkdir", "-p", "/mnt/data"}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
_, err = mkdirProc2.Wait(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

err = sb2.ExperimentalMountImage(ctx, "/mnt/data", snapshotImage)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

catProc, err := sb2.Exec(ctx, []string{"cat", "/mnt/data/snapshot.txt"}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
output, err := io.ReadAll(catProc.Stdout)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
g.Expect(string(output)).To(gomega.Equal("snapshot test content"))
}

func TestSandboxMountDirectoryWithUnbuiltImageThrows(t *testing.T) {
t.Parallel()
g := gomega.NewWithT(t)
ctx := context.Background()
tc := newTestClient(t)

app, err := tc.Apps.FromName(ctx, "libmodal-test", &modal.AppFromNameParams{CreateIfMissing: true})
g.Expect(err).ShouldNot(gomega.HaveOccurred())

baseImage := tc.Images.FromRegistry("debian:12-slim", nil)

sb, err := tc.Sandboxes.Create(ctx, app, baseImage, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
defer terminateSandbox(g, sb)

mkdirProc, err := sb.Exec(ctx, []string{"mkdir", "-p", "/mnt/data"}, nil)
g.Expect(err).ShouldNot(gomega.HaveOccurred())
_, err = mkdirProc.Wait(ctx)
g.Expect(err).ShouldNot(gomega.HaveOccurred())

unbuiltImage := tc.Images.FromRegistry("alpine:3.21", nil)
g.Expect(unbuiltImage.ImageID).To(gomega.Equal(""))

err = sb.ExperimentalMountImage(ctx, "/mnt/data", unbuiltImage)
g.Expect(err).Should(gomega.HaveOccurred())
g.Expect(err.Error()).To(gomega.ContainSubstring("Image must be built before mounting"))
}
Loading