-
Notifications
You must be signed in to change notification settings - Fork 17
sandbox: dynamic directory mount & snapshot - Go #243
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
base: ehdr/sb-command-router-go
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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) | ||
| } | ||
| } | ||
| 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) | ||
ehdr marked this conversation as resolved.
Show resolved
Hide resolved
|
||
|
|
||
| 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) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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")) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.