Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
31 commits
Select commit Hold shift + click to select a range
876d3b7
Only image mount
thomasjpfan Jan 26, 2026
2caf2ba
Less changes
thomasjpfan Jan 26, 2026
ffe8c14
Introduce detach back
thomasjpfan Jan 27, 2026
1175ead
Make sure terminate actually calls terminate
thomasjpfan Jan 27, 2026
4cb11a9
Remove the need for ALPN workaround
thomasjpfan Jan 27, 2026
a994b38
Add docs
thomasjpfan Jan 27, 2026
0a07097
Fix lint
thomasjpfan Jan 27, 2026
42a301a
Simplify tls implemetnation
thomasjpfan Jan 27, 2026
812a561
Give terminate error a higher priority
thomasjpfan Jan 27, 2026
9c89b64
Make diff smaller
thomasjpfan Jan 27, 2026
5dc47e5
Less diff
thomasjpfan Jan 27, 2026
982ccc2
Ensure that sandbox is attached before running any operation
thomasjpfan Jan 27, 2026
6c3a2a8
Less diff
thomasjpfan Jan 27, 2026
3eba12d
Remove ts detach
thomasjpfan Jan 27, 2026
b37410f
Allow for terminate to happen
thomasjpfan Jan 27, 2026
d06531d
Use two mutexes
thomasjpfan Jan 27, 2026
367125b
Use atomic bool
thomasjpfan Jan 27, 2026
831f1dd
Remove profile.TaskCommandRouterInsecure
thomasjpfan Jan 28, 2026
7365519
Terminate no longer detaches
thomasjpfan Jan 28, 2026
f9c6f6c
Add detach everywhere
thomasjpfan Jan 28, 2026
10db500
Update examples
thomasjpfan Jan 28, 2026
c395951
Add changelog
thomasjpfan Jan 28, 2026
e49f593
Add example to readme
thomasjpfan Jan 28, 2026
ec85b2b
Add ensureAttached to be extra safe
thomasjpfan Jan 28, 2026
11b08c3
Use a better error message
thomasjpfan Jan 28, 2026
fb06f43
Add detach and params to Sandbox.Terminate
thomasjpfan Jan 29, 2026
a4a0084
Update changelog
thomasjpfan Jan 29, 2026
f08632d
Fix linter
thomasjpfan Jan 29, 2026
ac9e6f0
Do not detach
thomasjpfan Jan 29, 2026
4c46034
Add terminate back in
thomasjpfan Jan 29, 2026
a07297a
Switch order
thomasjpfan Jan 29, 2026
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
3 changes: 3 additions & 0 deletions modal-go/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ type retryCallOption struct {
const (
apiEndpoint = "api.modal.com:443"
maxMessageSize = 100 * 1024 * 1024 // 100 MB
windowSize = 64 * 1024 * 1024 // 64 MiB
defaultRetryAttempts = 3
defaultRetryBaseDelay = 100 * time.Millisecond
defaultRetryMaxDelay = 1 * time.Second
Expand Down Expand Up @@ -334,6 +335,8 @@ func newClient(ctx context.Context, profile Profile, c *Client, customUnaryInter
conn, err := grpc.NewClient(
target,
grpc.WithTransportCredentials(creds),
grpc.WithInitialWindowSize(windowSize),
grpc.WithInitialConnWindowSize(windowSize),
grpc.WithDefaultCallOptions(
grpc.MaxCallRecvMsgSize(maxMessageSize),
grpc.MaxCallSendMsgSize(maxMessageSize),
Expand Down
47 changes: 28 additions & 19 deletions modal-go/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,32 @@ import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/pelletier/go-toml/v2"
)

// Profile holds a fully-resolved configuration ready for use by the client.
type Profile struct {
ServerURL string
TokenID string
TokenSecret string
Environment string
ImageBuilderVersion string
LogLevel string
ServerURL string
TokenID string
TokenSecret string
Environment string
ImageBuilderVersion string
LogLevel string
TaskCommandRouterInsecure bool
}

// rawProfile mirrors the TOML structure on disk.
type rawProfile struct {
ServerURL string `toml:"server_url"`
TokenID string `toml:"token_id"`
TokenSecret string `toml:"token_secret"`
Environment string `toml:"environment"`
ImageBuilderVersion string `toml:"image_builder_version"`
LogLevel string `toml:"loglevel"`
Active bool `toml:"active"`
ServerURL string `toml:"server_url"`
TokenID string `toml:"token_id"`
TokenSecret string `toml:"token_secret"`
Environment string `toml:"environment"`
ImageBuilderVersion string `toml:"image_builder_version"`
LogLevel string `toml:"loglevel"`
Active bool `toml:"active"`
TaskCommandRouterInsecure bool `toml:"task_command_router_insecure"`
}

type config map[string]rawProfile
Expand Down Expand Up @@ -96,13 +99,19 @@ func getProfile(name string, cfg config) Profile {
imageBuilderVersion := firstNonEmpty(os.Getenv("MODAL_IMAGE_BUILDER_VERSION"), raw.ImageBuilderVersion)
logLevel := firstNonEmpty(os.Getenv("MODAL_LOGLEVEL"), raw.LogLevel)

taskCommandRouterInsecure := raw.TaskCommandRouterInsecure
if envVal := os.Getenv("MODAL_TASK_COMMAND_ROUTER_INSECURE"); envVal != "" {
taskCommandRouterInsecure = strings.ToLower(envVal) == "true" || envVal == "1"
}

return Profile{
ServerURL: serverURL,
TokenID: tokenID,
TokenSecret: tokenSecret,
Environment: environment,
ImageBuilderVersion: imageBuilderVersion,
LogLevel: logLevel,
ServerURL: serverURL,
TokenID: tokenID,
TokenSecret: tokenSecret,
Environment: environment,
ImageBuilderVersion: imageBuilderVersion,
LogLevel: logLevel,
TaskCommandRouterInsecure: taskCommandRouterInsecure,
}
}

Expand Down
9 changes: 9 additions & 0 deletions modal-go/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,12 @@ type SandboxTimeoutError struct {
func (e SandboxTimeoutError) Error() string {
return "SandboxTimeoutError: " + e.Exception
}

// SandboxDetached is returned when running an operation on a detached sandbox object.
type SandboxDetached struct {
Exception string
}

func (e SandboxDetached) Error() string {
return "SandboxDetached: " + e.Exception
}
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)
}
}
Loading
Loading