diff --git a/internal/container/apple.go b/internal/container/apple.go index 5c9b7cac..8f607038 100644 --- a/internal/container/apple.go +++ b/internal/container/apple.go @@ -302,9 +302,9 @@ func (r *AppleRuntime) buildCreateArgs(cfg Config) ([]string, error) { args = append(args, "--volume", mountStr) } - // Tmpfs mounts (overlays for excluded directories) + // --mount (not --tmpfs) so we can set the mode; --tmpfs accepts no options. for _, tm := range cfg.TmpfsMounts { - args = append(args, "--tmpfs", tm.Target) + args = append(args, "--mount", fmt.Sprintf("type=tmpfs,destination=%s,mode=%o", tm.Target, tmpfsMode)) } // Image diff --git a/internal/container/apple_test.go b/internal/container/apple_test.go index a582cfc1..44b62441 100644 --- a/internal/container/apple_test.go +++ b/internal/container/apple_test.go @@ -174,7 +174,7 @@ func TestBuildCreateArgs(t *testing.T) { {Target: "/workspace/node_modules"}, }, }, - want: []string{"create", "--memory", "4096MB", "--dns", "8.8.8.8", "--dns", "8.8.4.4", "--tmpfs", "/workspace/node_modules", "ubuntu:22.04"}, + want: []string{"create", "--memory", "4096MB", "--dns", "8.8.8.8", "--dns", "8.8.4.4", "--mount", "type=tmpfs,destination=/workspace/node_modules,mode=1777", "ubuntu:22.04"}, }, { name: "with volume and tmpfs mounts", @@ -188,7 +188,7 @@ func TestBuildCreateArgs(t *testing.T) { {Target: "/workspace/.venv"}, }, }, - want: []string{"create", "--memory", "4096MB", "--dns", "8.8.8.8", "--dns", "8.8.4.4", "--volume", "/home/user/project:/workspace", "--tmpfs", "/workspace/node_modules", "--tmpfs", "/workspace/.venv", "ubuntu:22.04"}, + want: []string{"create", "--memory", "4096MB", "--dns", "8.8.8.8", "--dns", "8.8.4.4", "--volume", "/home/user/project:/workspace", "--mount", "type=tmpfs,destination=/workspace/node_modules,mode=1777", "--mount", "type=tmpfs,destination=/workspace/.venv,mode=1777", "ubuntu:22.04"}, }, { // Apple's container CLI has no --add-host equivalent. ExtraHosts must diff --git a/internal/container/docker.go b/internal/container/docker.go index 2a19aa02..110472c5 100644 --- a/internal/container/docker.go +++ b/internal/container/docker.go @@ -161,21 +161,12 @@ func (r *DockerRuntime) Ping(ctx context.Context) error { return nil } -// CreateContainer creates a new Docker container. -func (r *DockerRuntime) CreateContainer(ctx context.Context, cfg Config) (string, error) { - // Verify gVisor is still available if we're configured to use it - if r.ociRuntime == "runsc" && !r.gvisorAvailable() { - return "", fmt.Errorf("gVisor was available at startup but is no longer configured - did Docker daemon configuration change? %w", ErrGVisorNotAvailable) - } - - // Pull image if not present - if err := r.ensureImage(ctx, cfg.Image); err != nil { - return "", err - } - - // Convert mounts - mounts := make([]mount.Mount, 0, len(cfg.Mounts)+len(cfg.TmpfsMounts)) - for _, m := range cfg.Mounts { +// buildContainerMounts converts moat's MountConfig and TmpfsMount entries into +// Docker SDK mount.Mount structs. Tmpfs mounts follow bind mounts so overlays +// of paths inside a bind take effect. +func buildContainerMounts(binds []MountConfig, tmpfs []TmpfsMount) []mount.Mount { + mounts := make([]mount.Mount, 0, len(binds)+len(tmpfs)) + for _, m := range binds { mounts = append(mounts, mount.Mount{ Type: mount.TypeBind, Source: m.Source, @@ -183,14 +174,34 @@ func (r *DockerRuntime) CreateContainer(ctx context.Context, cfg Config) (string ReadOnly: m.ReadOnly, }) } - // Tmpfs mounts — overlays for excluded directories. - // Appended after bind mounts so tmpfs overlays subdirectories of bind-mounted paths. - for _, tm := range cfg.TmpfsMounts { + for _, tm := range tmpfs { + // "exec" overrides runc's default noexec — excluded paths like + // node_modules contain native binaries (turbo, esbuild) that must spawn. mounts = append(mounts, mount.Mount{ Type: mount.TypeTmpfs, Target: tm.Target, + TmpfsOptions: &mount.TmpfsOptions{ + Mode: tmpfsMode, + Options: [][]string{{"exec"}}, + }, }) } + return mounts +} + +// CreateContainer creates a new Docker container. +func (r *DockerRuntime) CreateContainer(ctx context.Context, cfg Config) (string, error) { + // Verify gVisor is still available if we're configured to use it + if r.ociRuntime == "runsc" && !r.gvisorAvailable() { + return "", fmt.Errorf("gVisor was available at startup but is no longer configured - did Docker daemon configuration change? %w", ErrGVisorNotAvailable) + } + + // Pull image if not present + if err := r.ensureImage(ctx, cfg.Image); err != nil { + return "", err + } + + mounts := buildContainerMounts(cfg.Mounts, cfg.TmpfsMounts) // Default to bridge network if not specified networkMode := container.NetworkMode(cfg.NetworkMode) @@ -999,16 +1010,7 @@ func (m *dockerSidecarManager) StartSidecar(ctx context.Context, cfg SidecarConf return "", fmt.Errorf("pulling sidecar image: %w", err) } - // Prepare mounts - mounts := make([]mount.Mount, 0, len(cfg.Mounts)) - for _, mt := range cfg.Mounts { - mounts = append(mounts, mount.Mount{ - Type: mount.TypeBind, - Source: mt.Source, - Target: mt.Target, - ReadOnly: mt.ReadOnly, - }) - } + mounts := buildContainerMounts(cfg.Mounts, nil) // Create container with labels for orphan cleanup labels := make(map[string]string) diff --git a/internal/container/docker_test.go b/internal/container/docker_test.go index 15393940..57d91028 100644 --- a/internal/container/docker_test.go +++ b/internal/container/docker_test.go @@ -3,16 +3,75 @@ package container import ( "context" "os" + "reflect" "strconv" "strings" "sync" "testing" "time" + "github.com/docker/docker/api/types/mount" "github.com/docker/docker/api/types/network" "github.com/docker/docker/client" ) +func TestBuildContainerMounts_TmpfsWritableAndExec(t *testing.T) { + got := buildContainerMounts(nil, []TmpfsMount{{Target: "/workspace/node_modules"}}) + + if len(got) != 1 { + t.Fatalf("got %d mounts, want 1", len(got)) + } + if got[0].Type != mount.TypeTmpfs { + t.Errorf("Type = %v, want %v", got[0].Type, mount.TypeTmpfs) + } + if got[0].TmpfsOptions == nil { + t.Fatal("TmpfsOptions is nil") + } + if got[0].TmpfsOptions.Mode != tmpfsMode { + t.Errorf("Mode = %o, want %o", got[0].TmpfsOptions.Mode, tmpfsMode) + } + wantOpts := [][]string{{"exec"}} + if !reflect.DeepEqual(got[0].TmpfsOptions.Options, wantOpts) { + t.Errorf("Options = %v, want %v", got[0].TmpfsOptions.Options, wantOpts) + } +} + +func TestBuildContainerMounts_BindMounts(t *testing.T) { + binds := []MountConfig{ + {Source: "/host/project", Target: "/workspace", ReadOnly: false}, + {Source: "/host/secret", Target: "/etc/secret", ReadOnly: true}, + } + + got := buildContainerMounts(binds, nil) + + want := []mount.Mount{ + {Type: mount.TypeBind, Source: "/host/project", Target: "/workspace", ReadOnly: false}, + {Type: mount.TypeBind, Source: "/host/secret", Target: "/etc/secret", ReadOnly: true}, + } + if !reflect.DeepEqual(got, want) { + t.Errorf("buildContainerMounts() = %#v, want %#v", got, want) + } +} + +// Tmpfs must follow binds in the output slice so overlays of paths inside a +// bind take effect on the daemon side. +func TestBuildContainerMounts_TmpfsAfterBind(t *testing.T) { + binds := []MountConfig{{Source: "/host/project", Target: "/workspace"}} + tmpfs := []TmpfsMount{{Target: "/workspace/node_modules"}} + + got := buildContainerMounts(binds, tmpfs) + + if len(got) != 2 { + t.Fatalf("got %d mounts, want 2", len(got)) + } + if got[0].Type != mount.TypeBind { + t.Errorf("mounts[0].Type = %v, want bind", got[0].Type) + } + if got[1].Type != mount.TypeTmpfs { + t.Errorf("mounts[1].Type = %v, want tmpfs", got[1].Type) + } +} + func TestConfig_GroupAdd(t *testing.T) { // Verify that GroupAdd field can be set on Config struct // and is properly typed as []string diff --git a/internal/container/runtime.go b/internal/container/runtime.go index c0317dc1..9636c52e 100644 --- a/internal/container/runtime.go +++ b/internal/container/runtime.go @@ -368,6 +368,11 @@ type TmpfsMount struct { Target string // absolute container path } +// tmpfsMode is the permission mode for tmpfs overlays. Sticky world-writable +// (01777) so non-root container users can write — runc otherwise defaults +// tmpfs to 0755 owned by root. +const tmpfsMode = 01777 + // ImageInfo contains information about a container image. type ImageInfo struct { ID string `json:"id"`