From 96890c8123f4f950f2fc56edcca684028c8e7bc9 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Thu, 14 May 2026 12:22:16 -0500 Subject: [PATCH] fix(config): suppress workspace mount when workspaceMount is empty string (#283) Per the devcontainer spec, setting workspaceMount to "" should suppress the default workspace mount. Previously the string type could not distinguish between omitted and explicitly empty, so "" fell through to the default bind mount. Change WorkspaceMount from string to *string so nil means omitted (use default mount) and *"" means suppress mount entirely. --- pkg/devcontainer/config.go | 4 ++-- pkg/devcontainer/config/config.go | 3 ++- pkg/devcontainer/config/extends.go | 2 +- pkg/devcontainer/config/result.go | 7 +++++-- pkg/devcontainer/config_test.go | 2 +- pkg/devcontainer/run.go | 15 ++++++++++++--- pkg/devcontainer/run_test.go | 23 +++++++++++++++++++---- pkg/devcontainer/single.go | 20 ++++++++++++++------ pkg/driver/kubernetes/run.go | 5 +++++ 9 files changed, 61 insertions(+), 20 deletions(-) diff --git a/pkg/devcontainer/config.go b/pkg/devcontainer/config.go index d7358c5f9..73c181e33 100644 --- a/pkg/devcontainer/config.go +++ b/pkg/devcontainer/config.go @@ -192,8 +192,8 @@ func (r *runner) substitute( if parsedConfig.WorkspaceFolder != "" { substitutionContext.ContainerWorkspaceFolder = parsedConfig.WorkspaceFolder } - if parsedConfig.WorkspaceMount != "" { - substitutionContext.WorkspaceMount = parsedConfig.WorkspaceMount + if parsedConfig.WorkspaceMount != nil { + substitutionContext.WorkspaceMount = *parsedConfig.WorkspaceMount } if options.WorkspaceMountConsistency != "" { diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index 6c1f6ae5d..0f83c60fa 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -219,7 +219,8 @@ type NonComposeBase struct { RunArgs []string `json:"runArgs,omitempty"` // The --mount parameter for docker run. The default is to mount the project folder at /workspaces/$project. - WorkspaceMount string `json:"workspaceMount,omitempty"` + // Per the devcontainer spec, empty string suppresses the default workspace mount. + WorkspaceMount *string `json:"workspaceMount,omitempty"` } type DockerfileContainer struct { diff --git a/pkg/devcontainer/config/extends.go b/pkg/devcontainer/config/extends.go index dee5196c8..b68ae114b 100644 --- a/pkg/devcontainer/config/extends.go +++ b/pkg/devcontainer/config/extends.go @@ -185,7 +185,7 @@ func mergeContainerScalars(result, child *DevContainerConfig) { if child.WorkspaceFolder != "" { result.WorkspaceFolder = child.WorkspaceFolder } - if child.WorkspaceMount != "" { + if child.WorkspaceMount != nil { result.WorkspaceMount = child.WorkspaceMount } if child.ShutdownAction != "" { diff --git a/pkg/devcontainer/config/result.go b/pkg/devcontainer/config/result.go index 652b184b4..d5630d325 100644 --- a/pkg/devcontainer/config/result.go +++ b/pkg/devcontainer/config/result.go @@ -26,8 +26,11 @@ type DevContainerConfigWithPath struct { } func GetMounts(result *Result) []*Mount { - workspaceMount := ParseMount(result.SubstitutionContext.WorkspaceMount) - mounts := []*Mount{&workspaceMount} + var mounts []*Mount + if result.SubstitutionContext.WorkspaceMount != "" { + workspaceMount := ParseMount(result.SubstitutionContext.WorkspaceMount) + mounts = append(mounts, &workspaceMount) + } for _, m := range result.MergedConfig.Mounts { if m.Type == "bind" { mounts = append(mounts, m) diff --git a/pkg/devcontainer/config_test.go b/pkg/devcontainer/config_test.go index 9cace35e7..ed28b7b37 100644 --- a/pkg/devcontainer/config_test.go +++ b/pkg/devcontainer/config_test.go @@ -267,7 +267,7 @@ func (s *SubstituteTestSuite) TestSubstitute_WorkspaceMountConsistencyReplacesEx rawConfig := &config.DevContainerConfig{ ImageContainer: config.ImageContainer{Image: "alpine:latest"}, NonComposeBase: config.NonComposeBase{ - WorkspaceMount: "type=bind,source=/src,target=/ws,consistency='consistent'", + WorkspaceMount: ptr("type=bind,source=/src,target=/ws,consistency='consistent'"), }, } options := provider2.CLIOptions{ diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index 25568744b..251e69fbf 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -350,9 +350,18 @@ func getWorkspace( workspaceFolder, workspaceID string, conf *config.DevContainerConfig, ) (string, string) { - if conf.WorkspaceMount != "" { - mount := config.ParseMount(conf.WorkspaceMount) - ws := conf.WorkspaceMount + if conf.WorkspaceMount != nil { + // Explicit empty string means suppress the workspace mount entirely. + if *conf.WorkspaceMount == "" { + containerMountFolder := conf.WorkspaceFolder + if containerMountFolder == "" { + containerMountFolder = "/workspaces/" + workspaceID + } + return "", containerMountFolder + } + + mount := config.ParseMount(*conf.WorkspaceMount) + ws := *conf.WorkspaceMount if needsDefaultConsistency() && !mountHasConsistency(ws) { ws += ",consistency='consistent'" } diff --git a/pkg/devcontainer/run_test.go b/pkg/devcontainer/run_test.go index 4f16cc7fc..444673a28 100644 --- a/pkg/devcontainer/run_test.go +++ b/pkg/devcontainer/run_test.go @@ -131,11 +131,13 @@ func searchString(s, substr string) bool { return false } +func strPtr(s string) *string { return &s } + func TestGetWorkspace_CustomWorkspaceMount(t *testing.T) { customMount := "type=bind,source=/host/src,target=/custom-ws" conf := &config.DevContainerConfig{ NonComposeBase: config.NonComposeBase{ - WorkspaceMount: customMount, + WorkspaceMount: strPtr(customMount), }, } @@ -185,17 +187,30 @@ func TestGetWorkspace_DefaultMountWithWorkspaceFolder(t *testing.T) { func TestGetWorkspace_EmptyWorkspaceMount(t *testing.T) { conf := &config.DevContainerConfig{ NonComposeBase: config.NonComposeBase{ - WorkspaceMount: "", + WorkspaceMount: strPtr(""), }, } mount, folder := getWorkspace("/home/user/project", "ws-id", conf) + if mount != "" { + t.Fatalf("expected empty mount string (suppressed), got %q", mount) + } if folder != "/workspaces/ws-id" { t.Fatalf("expected default folder, got %q", folder) } +} + +func TestGetWorkspace_NilWorkspaceMount(t *testing.T) { + conf := &config.DevContainerConfig{} + + mount, folder := getWorkspace("/home/user/project", "ws-id", conf) + if !contains(mount, "type=bind") { - t.Fatalf("expected default bind mount, got %q", mount) + t.Fatalf("expected default bind mount for nil WorkspaceMount, got %q", mount) + } + if folder != "/workspaces/ws-id" { + t.Fatalf("expected default folder, got %q", folder) } } @@ -203,7 +218,7 @@ func TestGetWorkspace_UserConsistencyPreserved(t *testing.T) { customMount := "type=bind,source=/src,target=/ws,consistency=delegated" conf := &config.DevContainerConfig{ NonComposeBase: config.NonComposeBase{ - WorkspaceMount: customMount, + WorkspaceMount: strPtr(customMount), }, } diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 884104803..a4adf329d 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -428,8 +428,12 @@ func (r *runner) getDockerlessRunOptions( substitutionContext *config.SubstitutionContext, buildInfo *config.BuildInfo, ) (*driver.RunOptions, error) { - // parse workspace mount - workspaceMountParsed := config.ParseMount(substitutionContext.WorkspaceMount) + // parse workspace mount — nil when suppressed via workspaceMount: "" + var workspaceMountPtr *config.Mount + if substitutionContext.WorkspaceMount != "" { + parsed := config.ParseMount(substitutionContext.WorkspaceMount) + workspaceMountPtr = &parsed + } // add metadata as label here marshalled, err := metadata.MarshalImageMetadata(buildInfo.ImageMetadata.Raw) @@ -496,7 +500,7 @@ func (r *runner) getDockerlessRunOptions( }, Privileged: mergedConfig.Privileged, Init: mergedConfig.Init, - WorkspaceMount: &workspaceMountParsed, + WorkspaceMount: workspaceMountPtr, Mounts: mounts, Userns: substitutionContext.Userns, UidMap: substitutionContext.UidMap, @@ -509,8 +513,12 @@ func (r *runner) getRunOptions( substitutionContext *config.SubstitutionContext, buildInfo *config.BuildInfo, ) (*driver.RunOptions, error) { - // parse workspace mount - workspaceMountParsed := config.ParseMount(substitutionContext.WorkspaceMount) + // parse workspace mount — nil when suppressed via workspaceMount: "" + var workspaceMountPtr *config.Mount + if substitutionContext.WorkspaceMount != "" { + parsed := config.ParseMount(substitutionContext.WorkspaceMount) + workspaceMountPtr = &parsed + } // add metadata as label here marshalled, err := metadata.MarshalImageMetadata(buildInfo.ImageMetadata.Raw) @@ -553,7 +561,7 @@ func (r *runner) getRunOptions( Labels: labels, Privileged: mergedConfig.Privileged, Init: mergedConfig.Init, - WorkspaceMount: &workspaceMountParsed, + WorkspaceMount: workspaceMountPtr, SecurityOpt: mergedConfig.SecurityOpt, Mounts: mergedConfig.Mounts, Userns: substitutionContext.Userns, diff --git a/pkg/driver/kubernetes/run.go b/pkg/driver/kubernetes/run.go index 3171d4b4c..6575a708e 100644 --- a/pkg/driver/kubernetes/run.go +++ b/pkg/driver/kubernetes/run.go @@ -104,6 +104,11 @@ func (k *KubernetesDriver) runContainer( ) (err error) { // get workspace mount mount := options.WorkspaceMount + if mount == nil { + return fmt.Errorf( + "workspace mount is suppressed; cannot run in Kubernetes without a workspace mount", + ) + } if mount.Target == "" { return fmt.Errorf("workspace mount target is empty") }