diff --git a/e2e/tests/up/provider_microsandbox.go b/e2e/tests/up/provider_microsandbox.go index fa4d33389..af6d31fee 100644 --- a/e2e/tests/up/provider_microsandbox.go +++ b/e2e/tests/up/provider_microsandbox.go @@ -2,12 +2,15 @@ package up import ( "context" + "fmt" "os" "os/exec" + "path/filepath" "runtime" "github.com/devsy-org/devsy/e2e/framework" "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" ) const osLinux = "linux" @@ -69,6 +72,34 @@ var _ = ginkgo.Describe( err = f.DevsySSHEchoTestString(ctx, tempDir) framework.ExpectNoError(err) + workspacePath := filepath.Join("/workspaces", filepath.Base(tempDir)) + guestFile := filepath.Join(tempDir, "guest-created.txt") + _, err = f.DevsySSHOnce(ctx, tempDir, fmt.Sprintf( + "touch %s/guest-created.txt && chmod 0644 %s/guest-created.txt", + workspacePath, workspacePath, + )) + framework.ExpectNoError(err) + guestInfo, err := os.Stat(guestFile) + framework.ExpectNoError(err) + gomega.Expect(guestInfo.Mode().Perm()).To(gomega.Equal(os.FileMode(0o644))) + + hostFile := filepath.Join(tempDir, "host-created.txt") + //nolint:gosec // permission mirroring is the behavior under test + err = os.WriteFile( + hostFile, + []byte("host"), + 0o644, + ) + framework.ExpectNoError(err) + //nolint:gosec // permission mirroring is the behavior under test + err = os.Chmod(hostFile, 0o644) + framework.ExpectNoError(err) + owner, err := f.DevsySSHOnce(ctx, tempDir, fmt.Sprintf( + "stat -c '%%U %%a' %s/host-created.txt", workspacePath, + )) + framework.ExpectNoError(err) + gomega.Expect(owner).To(gomega.ContainSubstring("vscode 644")) + // stop, then bring it back up and confirm it is reachable again err = f.DevsyWorkspaceStop(ctx, tempDir) framework.ExpectNoError(err) diff --git a/e2e/tests/up/testdata/microsandbox/.devcontainer.json b/e2e/tests/up/testdata/microsandbox/.devcontainer.json index ca9e68883..c8d7c4041 100644 --- a/e2e/tests/up/testdata/microsandbox/.devcontainer.json +++ b/e2e/tests/up/testdata/microsandbox/.devcontainer.json @@ -1,4 +1,6 @@ { "name": "microsandbox", - "image": "mcr.microsoft.com/devcontainers/base:ubuntu" + "image": "mcr.microsoft.com/devcontainers/base:ubuntu", + "containerUser": "vscode", + "remoteUser": "vscode" } diff --git a/pkg/driver/microsandbox/cliclient.go b/pkg/driver/microsandbox/cliclient.go index e1d2ef7ba..25f9d7f24 100644 --- a/pkg/driver/microsandbox/cliclient.go +++ b/pkg/driver/microsandbox/cliclient.go @@ -38,6 +38,18 @@ func (cliClient) EnsureInstalled(_ context.Context) error { ) } +func (cliClient) Version(ctx context.Context) (string, error) { + // #nosec G204 -- args are a resolved binary path and fixed version command + out, err := exec.CommandContext(ctx, msbBinary(), "--version").CombinedOutput() + if err != nil { + return "", fmt.Errorf( + "get microsandbox version: %s: %w", + strings.TrimSpace(string(out)), err, + ) + } + return strings.TrimSpace(string(out)), nil +} + func (cliClient) EnsureImage(ctx context.Context, imageRef string) error { if dockerImageExists(ctx, imageRef) { return loadFromDocker(ctx, imageRef) @@ -178,6 +190,9 @@ func runArgs(sandbox string, spec sandboxSpec) []string { func runtimeArgs(spec sandboxSpec) []string { var args []string + if spec.User != "" { + args = append(args, "--user", spec.User) + } for k, v := range spec.Env { args = append(args, names.Flag(names.Env), k+"="+v) } @@ -228,11 +243,7 @@ func mountArgs(mounts []volumeMount) []string { case m.Volume != "": args = append(args, "--mount-named", m.Volume+":"+m.Target) case m.Source != "": - spec := m.Source + ":" + m.Target - if m.ReadOnly { - spec += ":ro" - } - args = append(args, "--mount-dir", spec) + args = append(args, "--mount-dir", bindMountSpec(m)) } } return args diff --git a/pkg/driver/microsandbox/cliclient_test.go b/pkg/driver/microsandbox/cliclient_test.go index 373ff72eb..0ff80ff49 100644 --- a/pkg/driver/microsandbox/cliclient_test.go +++ b/pkg/driver/microsandbox/cliclient_test.go @@ -77,6 +77,13 @@ func TestRunArgsMinimal(t *testing.T) { } } +func TestRunArgsPropagatesUser(t *testing.T) { + args := runArgs(wsName, sandboxSpec{Image: testImg, User: "vscode"}) + if !hasFlagValue(args, "--user", "vscode") { + t.Fatalf("args = %v, want --user vscode", args) + } +} + func TestRunArgsCmdWithoutEntrypoint(t *testing.T) { args := runArgs(wsName, sandboxSpec{Image: testImg, Cmd: []string{"python3", "worker.py"}}) if hasFlag(args, "--entrypoint") { @@ -117,6 +124,32 @@ func TestMountArgsAndNamedVolumes(t *testing.T) { } } +func TestMountArgsWorkspacePolicyAndOwner(t *testing.T) { + args := mountArgs([]volumeMount{ + {Target: testBindDst, Source: testBindSrc, Policy: mountPolicy{ + StatVirtualization: statVirtStrict, + HostPermissions: hostPermissionsMirror, + Owner: &mountOwner{UID: 1000, GID: 1001}, + }}, + }) + want := testBindSrc + ":" + testBindDst + ":stat-virt=strict,host-perms=mirror,uid=1000,gid=1001" + if !hasFlagValue(args, "--mount-dir", want) { + t.Fatalf("mountArgs = %v, want %q", args, want) + } +} + +func TestParseMicrosandboxVersion(t *testing.T) { + for _, input := range []string{"microsandbox " + testVersion, "msb " + testVersion, "v" + testVersion, testVersion} { + version, err := parseMicrosandboxVersion(input) + if err != nil || version.String() != testVersion { + t.Errorf("parseMicrosandboxVersion(%q) = %v, %v", input, version, err) + } + } + if _, err := parseMicrosandboxVersion("not a version"); err == nil { + t.Error("malformed version should fail") + } +} + func TestResourceArgsOmitsZero(t *testing.T) { if got := resourceArgs(sandboxSpec{}); len(got) != 0 { t.Errorf("zero spec should produce no resource args, got %v", got) diff --git a/pkg/driver/microsandbox/client.go b/pkg/driver/microsandbox/client.go index 65f080c74..ac0a325a6 100644 --- a/pkg/driver/microsandbox/client.go +++ b/pkg/driver/microsandbox/client.go @@ -8,6 +8,7 @@ import ( type sandboxSpec struct { Image string + User string Entrypoint string Cmd []string Memory uint32 @@ -29,6 +30,7 @@ type volumeMount struct { Volume string Tmpfs bool ReadOnly bool + Policy mountPolicy } type sandboxInfo struct { @@ -52,6 +54,7 @@ type execRequest struct { // job, not the client's. type sandboxClient interface { EnsureInstalled(ctx context.Context) error + Version(ctx context.Context) (string, error) EnsureImage(ctx context.Context, image string) error Create(ctx context.Context, name string, spec sandboxSpec) error Find(ctx context.Context, name string) (*sandboxInfo, error) diff --git a/pkg/driver/microsandbox/microsandbox.go b/pkg/driver/microsandbox/microsandbox.go index 100cef53d..3e0689427 100644 --- a/pkg/driver/microsandbox/microsandbox.go +++ b/pkg/driver/microsandbox/microsandbox.go @@ -8,11 +8,13 @@ import ( "fmt" "io" "math" + "regexp" "runtime" "strconv" "strings" "time" + "github.com/blang/semver/v4" pkgconfig "github.com/devsy-org/devsy/pkg/config" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/driver" @@ -38,11 +40,12 @@ type specDefaults struct { } type microsandboxDriver struct { - client sandboxClient - idLabels []string - defaults specDefaults - workspaceInfo *provider.AgentWorkspaceInfo - docker driver.ImageDriver + client sandboxClient + idLabels []string + defaults specDefaults + workspaceInfo *provider.AgentWorkspaceInfo + docker driver.ImageDriver + workspaceMountPolicy workspaceMountPolicy } var ( @@ -52,12 +55,47 @@ var ( _ driver.Preflighter = (*microsandboxDriver)(nil) ) +var minimumMicrosandboxVersion = semver.MustParse("0.7.2") + +var microsandboxVersionPattern = regexp.MustCompile( + `(?:^|[^0-9])v?(\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?)`, +) + +func parseMicrosandboxVersion(output string) (semver.Version, error) { + matches := microsandboxVersionPattern.FindStringSubmatch(output) + if len(matches) != 2 { + return semver.Version{}, fmt.Errorf("unable to parse microsandbox version from %q", output) + } + version, err := semver.Parse(matches[1]) + if err != nil { + return semver.Version{}, fmt.Errorf( + "unable to parse microsandbox version from %q: %w", output, err, + ) + } + return version, nil +} + // Preflight checks the microsandbox runtime binary is installed. There is no // daemon to auto-start, so a missing binary is surfaced for the user. func (d *microsandboxDriver) Preflight(ctx context.Context, _ driver.PreflightOptions) error { if err := d.client.EnsureInstalled(ctx); err != nil { return &driver.PreflightError{Provider: provider.MicrosandboxDriver, Err: err} } + rawVersion, err := d.client.Version(ctx) + if err != nil { + return &driver.PreflightError{Provider: provider.MicrosandboxDriver, Err: err} + } + version, err := parseMicrosandboxVersion(rawVersion) + if err != nil { + return &driver.PreflightError{Provider: provider.MicrosandboxDriver, Err: err} + } + if version.LT(minimumMicrosandboxVersion) { + return &driver.PreflightError{Provider: provider.MicrosandboxDriver, Err: fmt.Errorf( + "microsandbox %s is too old for Devsy workspace ownership synchronization; "+ + "v%s or newer is required. Update with `msb self update`", + version, minimumMicrosandboxVersion, + )} + } return nil } @@ -84,18 +122,29 @@ func NewMicrosandboxDriver( blockEgress: cfg.BlockEgress == pkgconfig.BoolTrue, rootDiskGB: parseUint32(cfg.Storage), } + workspacePolicy, err := parseWorkspaceMountPolicy(cfg) + if err != nil { + return nil, err + } log.Debugf( "using microsandbox driver: memory=%dMiB cpus=%d ephemeral=%t idleTimeout=%s", defaults.memory, defaults.cpus, defaults.ephemeral, defaults.idleTimeout, ) d := newDriver(client, workspaceInfo.CLIOptions.IDLabels, defaults) + d.workspaceMountPolicy = workspacePolicy d.workspaceInfo = workspaceInfo return d, nil } func newDriver(client sandboxClient, idLabels []string, defaults specDefaults) *microsandboxDriver { - return µsandboxDriver{client: client, idLabels: idLabels, defaults: defaults} + return µsandboxDriver{ + client: client, idLabels: idLabels, defaults: defaults, + workspaceMountPolicy: workspaceMountPolicy{ + StatVirtualization: statVirtStrict, + HostPermissions: hostPermissionsMirror, + }, + } } func (d *microsandboxDriver) RunDevContainer( @@ -254,6 +303,9 @@ func (d *microsandboxDriver) UpdateContainerUserUID( _ *config.DevContainerConfig, _ io.Writer, ) error { + // UpdateContainerUserUID is intentionally a no-op for MicroSandbox. + // Docker rewrites container identity to match the host for bind mounts; + // MicroSandbox virtualizes ownership on virtiofs mounts instead. return nil } @@ -381,6 +433,7 @@ func (d *microsandboxDriver) buildSpec( } return sandboxSpec{ Image: options.Image, + User: options.User, Entrypoint: options.Entrypoint, Cmd: options.Cmd, Memory: memory, @@ -389,7 +442,7 @@ func (d *microsandboxDriver) buildSpec( Labels: labels, Ephemeral: d.defaults.ephemeral, IdleTimeout: d.defaults.idleTimeout, - Mounts: volumeMounts(options), + Mounts: d.volumeMounts(options), MaxMemory: d.defaults.maxMemory, MaxCPUs: d.defaults.maxCPUs, BlockEgress: d.defaults.blockEgress, @@ -397,43 +450,6 @@ func (d *microsandboxDriver) buildSpec( } } -func volumeMounts(options *driver.RunOptions) []volumeMount { - var out []volumeMount - if b := bindMount(options.WorkspaceMount); b != nil { - out = append(out, *b) - } - for _, m := range options.Mounts { - if vm, ok := toVolumeMount(m); ok { - out = append(out, vm) - } - } - return out -} - -func toVolumeMount(m *config.Mount) (volumeMount, bool) { - if m == nil || m.Target == "" { - return volumeMount{}, false - } - switch m.Type { - case driver.MountTypeBind: - if b := bindMount(m); b != nil { - return *b, true - } - case driver.MountTypeVolume: - return volumeMount{Target: m.Target, Volume: m.Source}, true - case driver.MountTypeTmpfs: - return volumeMount{Target: m.Target, Tmpfs: true}, true - } - return volumeMount{}, false -} - -func bindMount(m *config.Mount) *volumeMount { - if m == nil || m.Source == "" || m.Target == "" { - return nil - } - return &volumeMount{Target: m.Target, Source: m.Source, ReadOnly: m.IsReadOnly()} -} - func warnUnsupportedOptions(options *driver.RunOptions) { var ignored []string if isPrivileged(options) { diff --git a/pkg/driver/microsandbox/microsandbox_test.go b/pkg/driver/microsandbox/microsandbox_test.go index 770d74f4c..e693abb24 100644 --- a/pkg/driver/microsandbox/microsandbox_test.go +++ b/pkg/driver/microsandbox/microsandbox_test.go @@ -21,6 +21,7 @@ const ( testUser = "vscode" imgX = "x:1" testImg = "img:1" + testVersion = "0.7.2" shPath = "/bin/sh" callFind = "find:" + wsName callRemove = "remove:" + wsName @@ -42,6 +43,7 @@ type fakeClient struct { failCreat error failEnsure error failInstall error + version string } func newFakeClient() *fakeClient { @@ -50,6 +52,13 @@ func newFakeClient() *fakeClient { func (f *fakeClient) EnsureInstalled(context.Context) error { return f.failInstall } +func (f *fakeClient) Version(context.Context) (string, error) { + if f.version != "" { + return f.version, nil + } + return testVersion, nil +} + func (f *fakeClient) EnsureImage(_ context.Context, image string) error { f.calls = append(f.calls, "ensure:"+image) return f.failEnsure @@ -503,7 +512,16 @@ func TestBuildSpecMapsWorkspaceMount(t *testing.T) { Target: testBindDst, }, }, nil) - want := []volumeMount{{Target: testBindDst, Source: testBindSrc}} + want := []volumeMount{ + { + Target: testBindDst, + Source: testBindSrc, + Policy: mountPolicy{ + StatVirtualization: statVirtStrict, + HostPermissions: hostPermissionsMirror, + }, + }, + } if !slices.Equal(spec.Mounts, want) { t.Errorf("mounts = %+v, want %+v", spec.Mounts, want) } diff --git a/pkg/driver/microsandbox/mounts.go b/pkg/driver/microsandbox/mounts.go new file mode 100644 index 000000000..49742eb28 --- /dev/null +++ b/pkg/driver/microsandbox/mounts.go @@ -0,0 +1,164 @@ +package microsandbox + +import ( + "fmt" + "strings" + + devcontainerconfig "github.com/devsy-org/devsy/pkg/devcontainer/config" + "github.com/devsy-org/devsy/pkg/driver" + "github.com/devsy-org/devsy/pkg/provider" +) + +type statVirtualization string + +const ( + statVirtStrict statVirtualization = "strict" + statVirtRelaxed statVirtualization = "relaxed" + statVirtOff statVirtualization = "off" +) + +type hostPermissions string + +const ( + hostPermissionsPrivate hostPermissions = "private" + hostPermissionsMirror hostPermissions = "mirror" +) + +type mountOwner struct { + UID uint32 + GID uint32 +} + +type mountPolicy struct { + StatVirtualization statVirtualization + HostPermissions hostPermissions + Owner *mountOwner +} + +type workspaceMountPolicy struct { + StatVirtualization statVirtualization + HostPermissions hostPermissions +} + +func parseWorkspaceMountPolicy( + cfg provider.ProviderMicrosandboxDriverConfig, +) (workspaceMountPolicy, error) { + stat, err := parseStatVirtualization(cfg.WorkspaceStatVirtualization) + if err != nil { + return workspaceMountPolicy{}, err + } + host, err := parseHostPermissions(cfg.WorkspaceHostPermissions) + if err != nil { + return workspaceMountPolicy{}, err + } + policy := workspaceMountPolicy{StatVirtualization: stat, HostPermissions: host} + if policy.StatVirtualization == statVirtOff && policy.HostPermissions == hostPermissionsMirror { + return workspaceMountPolicy{}, fmt.Errorf( + "invalid microsandbox workspace mount policy: host-perms=mirror requires " + + "stat virtualization; use strict/relaxed or set host permissions to private", + ) + } + return policy, nil +} + +func parseStatVirtualization(value string) (statVirtualization, error) { + policy := statVirtualization(strings.TrimSpace(value)) + switch policy { + case "": + return statVirtStrict, nil + case statVirtStrict, statVirtRelaxed, statVirtOff: + return policy, nil + default: + return "", fmt.Errorf("invalid microsandbox workspace stat virtualization %q", policy) + } +} + +func parseHostPermissions(value string) (hostPermissions, error) { + policy := hostPermissions(strings.TrimSpace(value)) + switch policy { + case "": + return hostPermissionsMirror, nil + case hostPermissionsMirror, hostPermissionsPrivate: + return policy, nil + default: + return "", fmt.Errorf("invalid microsandbox workspace host permissions %q", policy) + } +} + +func (d *microsandboxDriver) volumeMounts(options *driver.RunOptions) []volumeMount { + var out []volumeMount + if m := d.workspaceMount(options.WorkspaceMount); m != nil { + out = append(out, *m) + } + for _, m := range options.Mounts { + if vm, ok := toVolumeMount(m); ok { + out = append(out, vm) + } + } + return out +} + +func (d *microsandboxDriver) workspaceMount(m *devcontainerconfig.Mount) *volumeMount { + b := bindMount(m) + if b == nil { + return nil + } + b.Policy = mountPolicy{ + StatVirtualization: d.workspaceMountPolicy.StatVirtualization, + HostPermissions: d.workspaceMountPolicy.HostPermissions, + } + return b +} + +func toVolumeMount(m *devcontainerconfig.Mount) (volumeMount, bool) { + if m == nil || m.Target == "" { + return volumeMount{}, false + } + switch m.Type { + case driver.MountTypeBind: + if b := bindMount(m); b != nil { + return *b, true + } + case driver.MountTypeVolume: + return volumeMount{Target: m.Target, Volume: m.Source}, true + case driver.MountTypeTmpfs: + return volumeMount{Target: m.Target, Tmpfs: true}, true + } + return volumeMount{}, false +} + +func bindMount(m *devcontainerconfig.Mount) *volumeMount { + if m == nil || m.Source == "" || m.Target == "" { + return nil + } + return &volumeMount{Target: m.Target, Source: m.Source, ReadOnly: m.IsReadOnly()} +} + +func mountOptions(m volumeMount) []string { + var options []string + if m.ReadOnly { + options = append(options, "ro") + } + if m.Policy.StatVirtualization != "" { + options = append(options, "stat-virt="+string(m.Policy.StatVirtualization)) + } + if m.Policy.HostPermissions != "" { + options = append(options, "host-perms="+string(m.Policy.HostPermissions)) + } + if m.Policy.Owner != nil { + options = append( + options, + fmt.Sprintf("uid=%d", m.Policy.Owner.UID), + fmt.Sprintf("gid=%d", m.Policy.Owner.GID), + ) + } + return options +} + +func bindMountSpec(m volumeMount) string { + spec := m.Source + ":" + m.Target + if options := mountOptions(m); len(options) > 0 { + spec += ":" + strings.Join(options, ",") + } + return spec +} diff --git a/pkg/driver/microsandbox/preflight_test.go b/pkg/driver/microsandbox/preflight_test.go index 253aaa5e0..b780c5fb8 100644 --- a/pkg/driver/microsandbox/preflight_test.go +++ b/pkg/driver/microsandbox/preflight_test.go @@ -3,6 +3,7 @@ package microsandbox import ( "context" "errors" + "strings" "testing" "github.com/devsy-org/devsy/pkg/driver" @@ -29,3 +30,14 @@ func TestPreflightNotInstalled(t *testing.T) { t.Fatalf("Provider = %q, want microsandbox", perr.Provider) } } + +func TestPreflightRejectsOldRuntime(t *testing.T) { + c := newFakeClient() + c.version = "microsandbox 0.7.1" + d := newDriver(c, nil, specDefaults{}) + + err := d.Preflight(context.Background(), driver.PreflightOptions{}) + if err == nil || !strings.Contains(err.Error(), "v0.7.2 or newer is required") { + t.Fatalf("Preflight error = %v, want minimum-version guidance", err) + } +} diff --git a/pkg/options/resolve.go b/pkg/options/resolve.go index 913dda176..40397f8ea 100644 --- a/pkg/options/resolve.go +++ b/pkg/options/resolve.go @@ -398,6 +398,14 @@ func resolveAgentMicrosandboxConfig( agentConfig.Microsandbox.BlockEgress = types.StrBool( resolver.ResolveDefaultValue(string(agentConfig.Microsandbox.BlockEgress), options), ) + agentConfig.Microsandbox.WorkspaceHostPermissions = resolver.ResolveDefaultValue( + agentConfig.Microsandbox.WorkspaceHostPermissions, + options, + ) + agentConfig.Microsandbox.WorkspaceStatVirtualization = resolver.ResolveDefaultValue( + agentConfig.Microsandbox.WorkspaceStatVirtualization, + options, + ) agentConfig.Microsandbox.Storage = resolver.ResolveDefaultValue( agentConfig.Microsandbox.Storage, options, diff --git a/pkg/options/resolve_test.go b/pkg/options/resolve_test.go index 9bb5fa185..61ef45cc1 100644 --- a/pkg/options/resolve_test.go +++ b/pkg/options/resolve_test.go @@ -807,14 +807,18 @@ func TestResolveAgentMicrosandboxConfig(t *testing.T) { agentConfig.Microsandbox.MaxMemory = "${MICROSANDBOX_MAX_MEMORY}" agentConfig.Microsandbox.BlockEgress = types.StrBool("${MICROSANDBOX_BLOCK_EGRESS}") agentConfig.Microsandbox.Storage = "${MICROSANDBOX_STORAGE}" + agentConfig.Microsandbox.WorkspaceHostPermissions = "${MICROSANDBOX_WORKSPACE_HOST_PERMISSIONS}" + agentConfig.Microsandbox.WorkspaceStatVirtualization = "${MICROSANDBOX_WORKSPACE_STAT_VIRTUALIZATION}" options := map[string]string{ - "MICROSANDBOX_MEMORY": "2048", - "MICROSANDBOX_CPUS": "4", - "MICROSANDBOX_EPHEMERAL": "true", - "MICROSANDBOX_MAX_MEMORY": "8192", - "MICROSANDBOX_BLOCK_EGRESS": "true", - "MICROSANDBOX_STORAGE": "32", + "MICROSANDBOX_MEMORY": "2048", + "MICROSANDBOX_CPUS": "4", + "MICROSANDBOX_EPHEMERAL": "true", + "MICROSANDBOX_MAX_MEMORY": "8192", + "MICROSANDBOX_BLOCK_EGRESS": "true", + "MICROSANDBOX_STORAGE": "32", + "MICROSANDBOX_WORKSPACE_HOST_PERMISSIONS": "private", + "MICROSANDBOX_WORKSPACE_STAT_VIRTUALIZATION": "relaxed", } resolveAgentMicrosandboxConfig(agentConfig, options) @@ -825,6 +829,8 @@ func TestResolveAgentMicrosandboxConfig(t *testing.T) { assert.Equal(t, "8192", agentConfig.Microsandbox.MaxMemory) assert.Equal(t, types.StrBool("true"), agentConfig.Microsandbox.BlockEgress) assert.Equal(t, "32", agentConfig.Microsandbox.Storage) + assert.Equal(t, "private", agentConfig.Microsandbox.WorkspaceHostPermissions) + assert.Equal(t, "relaxed", agentConfig.Microsandbox.WorkspaceStatVirtualization) } func TestResolveAgentDownloadURL(t *testing.T) { diff --git a/pkg/provider/provider.go b/pkg/provider/provider.go index 5f200111f..7b4ad9e4b 100644 --- a/pkg/provider/provider.go +++ b/pkg/provider/provider.go @@ -355,6 +355,12 @@ type ProviderMicrosandboxDriverConfig struct { // Storage is the OCI root disk size in GiB. Empty uses the runtime default. Storage string `json:"storage,omitempty"` + + // WorkspaceHostPermissions controls host permission mirroring for the primary workspace bind mount. + WorkspaceHostPermissions string `json:"workspaceHostPermissions,omitempty"` + + // WorkspaceStatVirtualization controls stat virtualization for the primary workspace bind mount. + WorkspaceStatVirtualization string `json:"workspaceStatVirtualization,omitempty"` } type ProviderCustomDriverConfig struct { diff --git a/providers/microsandbox/provider.yaml b/providers/microsandbox/provider.yaml index 8d72ab0ee..8e438bbf1 100644 --- a/providers/microsandbox/provider.yaml +++ b/providers/microsandbox/provider.yaml @@ -13,6 +13,8 @@ optionGroups: - MICROSANDBOX_STORAGE - MICROSANDBOX_BLOCK_EGRESS - MICROSANDBOX_EPHEMERAL + - MICROSANDBOX_WORKSPACE_HOST_PERMISSIONS + - MICROSANDBOX_WORKSPACE_STAT_VIRTUALIZATION - INACTIVITY_TIMEOUT name: "Advanced Options" options: @@ -35,6 +37,24 @@ options: description: "If true, boot from a tmpfs root disk so the microVM's disk state is discarded when it stops. Sized by MICROSANDBOX_STORAGE/hostRequirements.storage, or 8GiB if neither is set." default: "false" type: boolean + MICROSANDBOX_WORKSPACE_HOST_PERMISSIONS: + description: "Workspace bind-mount host permission policy." + default: "mirror" + enum: + - value: mirror + displayName: "Mirror guest rwx mode changes to the host workspace." + - value: private + displayName: "Keep guest chmod changes in MicroSandbox metadata only." + MICROSANDBOX_WORKSPACE_STAT_VIRTUALIZATION: + description: "Workspace bind-mount stat virtualization policy." + default: "strict" + enum: + - value: strict + displayName: "Require stat virtualization support." + - value: relaxed + displayName: "Use stat virtualization where supported." + - value: off + displayName: "Expose literal host ownership and modes." INACTIVITY_TIMEOUT: description: "If defined, will automatically stop the microVM after the inactivity period. Examples: 10m, 1h" agent: @@ -49,6 +69,8 @@ agent: storage: ${MICROSANDBOX_STORAGE} blockEgress: ${MICROSANDBOX_BLOCK_EGRESS} ephemeral: ${MICROSANDBOX_EPHEMERAL} + workspaceHostPermissions: ${MICROSANDBOX_WORKSPACE_HOST_PERMISSIONS} + workspaceStatVirtualization: ${MICROSANDBOX_WORKSPACE_STAT_VIRTUALIZATION} exec: command: |- "${DEVSY}" internal sh -c "${COMMAND}" diff --git a/sites/docs-devsy-sh/content/docs/developing-providers/driver.mdx b/sites/docs-devsy-sh/content/docs/developing-providers/driver.mdx index cac039cef..70bc016a3 100644 --- a/sites/docs-devsy-sh/content/docs/developing-providers/driver.mdx +++ b/sites/docs-devsy-sh/content/docs/developing-providers/driver.mdx @@ -158,6 +158,22 @@ Available options: - **ephemeral**: if true, boot from a tmpfs root disk so the microVM's disk state is discarded when it stops. Sized by `storage`/`hostRequirements.storage`, or 8GiB if neither is set. +- **workspaceHostPermissions**: controls chmod behavior for the primary workspace + bind mount. `mirror` (the default) mirrors guest rwx changes to the host; + `private` keeps them in MicroSandbox metadata. +- **workspaceStatVirtualization**: controls workspace ownership and metadata + virtualization. `strict` (the default) requires compatible host metadata + support; `relaxed` tolerates filesystems without it; `off` exposes literal + host ownership and modes. `off` cannot be combined with `mirror`. + +The workspace mount uses `stat-virt=strict` and `host-perms=mirror` by default, +while additional bind mounts retain MicroSandbox's safer defaults. Devsy passes +the resolved devcontainer `containerUser` to the MicroSandbox runtime so files +created on the host appear as the effective sandbox user in the guest. Install +MicroSandbox v0.7.2 or newer; Devsy requires this version for workspace +ownership synchronization and lifecycle operations. Apple Silicon macOS is the canonical supported +integration-test host; use `relaxed` when a host filesystem cannot provide +strict metadata virtualization. ```yaml agent: @@ -167,6 +183,8 @@ agent: memory: "2048" blockEgress: "false" ephemeral: "false" + workspaceHostPermissions: "mirror" + workspaceStatVirtualization: "strict" ``` ## Custom Driver