diff --git a/cmd/up.go b/cmd/up.go index d87f3050c..d4ad540d7 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -33,6 +33,12 @@ import ( "github.com/spf13/cobra" ) +const ( + MountConsistencyConsistent = "consistent" + MountConsistencyCached = "cached" + MountConsistencyDelegated = "delegated" +) + // UpCmd holds the up cmd flags. type UpCmd struct { provider2.CLIOptions @@ -144,6 +150,17 @@ func (cmd *UpCmd) validate() error { } cmd.ExtraDevContainerPath = absPath } + if cmd.WorkspaceMountConsistency != "" { + switch cmd.WorkspaceMountConsistency { + case MountConsistencyConsistent, MountConsistencyCached, MountConsistencyDelegated: + default: + return fmt.Errorf( + "invalid --workspace-mount-consistency value %q: must be one of %s, %s, %s", + cmd.WorkspaceMountConsistency, + MountConsistencyConsistent, MountConsistencyCached, MountConsistencyDelegated, + ) + } + } return nil } @@ -298,6 +315,9 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { StringArrayVar(&cmd.CacheFrom, "cache-from", []string{}, "Cache sources for the build (e.g., myregistry.io/cache:latest or type=registry,ref=...). "+ "Takes priority over devcontainer.json build.cacheFrom") + upCmd.Flags(). + StringVar(&cmd.WorkspaceMountConsistency, "workspace-mount-consistency", "", + "Consistency mode for the workspace bind mount (consistent, cached, delegated)") } func (cmd *UpCmd) registerTestingFlags(upCmd *cobra.Command) { diff --git a/cmd/up_test.go b/cmd/up_test.go index 602642f47..fe38cbe9c 100644 --- a/cmd/up_test.go +++ b/cmd/up_test.go @@ -56,3 +56,59 @@ func TestUpCmd_FlagParsesValue(t *testing.T) { flag := upCmd.Flags().Lookup("default-user-env-probe") assert.Equal(t, probeNone, flag.Value.String()) } + +func TestUpCmd_WorkspaceMountConsistencyFlag(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + flag := upCmd.Flags().Lookup("workspace-mount-consistency") + require.NotNil(t, flag) + assert.Equal(t, "", flag.DefValue) +} + +func TestUpCmd_WorkspaceMountConsistencyFlagParsesValue(t *testing.T) { + tests := []struct { + name string + value string + }{ + {name: "consistent", value: MountConsistencyConsistent}, + {name: "cached", value: MountConsistencyCached}, + {name: "delegated", value: MountConsistencyDelegated}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + err := upCmd.ParseFlags([]string{"--workspace-mount-consistency", tt.value}) + require.NoError(t, err) + + flag := upCmd.Flags().Lookup("workspace-mount-consistency") + assert.Equal(t, tt.value, flag.Value.String()) + }) + } +} + +func TestUpCmd_ValidateWorkspaceMountConsistency(t *testing.T) { + tests := []struct { + name string + value string + wantErr bool + }{ + {name: "empty is valid", value: "", wantErr: false}, + {name: "consistent", value: MountConsistencyConsistent, wantErr: false}, + {name: "cached", value: MountConsistencyCached, wantErr: false}, + {name: "delegated", value: MountConsistencyDelegated, wantErr: false}, + {name: "invalid value", value: "bogus", wantErr: true}, + {name: "partial match", value: "cache", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} + cmd.WorkspaceMountConsistency = tt.value + err := cmd.validate() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --workspace-mount-consistency") + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/pkg/devcontainer/config.go b/pkg/devcontainer/config.go index ada9ccba5..6a0800931 100644 --- a/pkg/devcontainer/config.go +++ b/pkg/devcontainer/config.go @@ -189,6 +189,13 @@ func (r *runner) substitute( substitutionContext.WorkspaceMount = parsedConfig.WorkspaceMount } + if options.WorkspaceMountConsistency != "" { + substitutionContext.WorkspaceMount = mountSetConsistency( + substitutionContext.WorkspaceMount, + options.WorkspaceMountConsistency, + ) + } + if options.DevContainerImage != "" { parsedConfig.Build = nil parsedConfig.Dockerfile = "" diff --git a/pkg/devcontainer/config_test.go b/pkg/devcontainer/config_test.go index db7c604a1..9cace35e7 100644 --- a/pkg/devcontainer/config_test.go +++ b/pkg/devcontainer/config_test.go @@ -247,4 +247,52 @@ func (s *SubstituteTestSuite) TestSubstitute_AdditionalFeaturesEmpty() { s.Nil(result.Config.Features) } +func (s *SubstituteTestSuite) TestSubstitute_WorkspaceMountConsistencyApplied() { + const testVal = "delegated" + rawConfig := &config.DevContainerConfig{ + ImageContainer: config.ImageContainer{Image: "alpine:latest"}, + } + options := provider2.CLIOptions{ + WorkspaceMountConsistency: testVal, + } + + _, ctx, err := s.runner.substitute(options, rawConfig) + + s.NoError(err) + s.Contains(ctx.WorkspaceMount, "consistency='"+testVal+"'") +} + +func (s *SubstituteTestSuite) TestSubstitute_WorkspaceMountConsistencyReplacesExisting() { + const testVal = "delegated" + rawConfig := &config.DevContainerConfig{ + ImageContainer: config.ImageContainer{Image: "alpine:latest"}, + NonComposeBase: config.NonComposeBase{ + WorkspaceMount: "type=bind,source=/src,target=/ws,consistency='consistent'", + }, + } + options := provider2.CLIOptions{ + WorkspaceMountConsistency: testVal, + } + + _, ctx, err := s.runner.substitute(options, rawConfig) + + s.NoError(err) + s.Contains(ctx.WorkspaceMount, "consistency='"+testVal+"'") + s.NotContains(ctx.WorkspaceMount, "consistency='consistent'") +} + +func (s *SubstituteTestSuite) TestSubstitute_WorkspaceMountConsistencyEmpty() { + rawConfig := &config.DevContainerConfig{ + ImageContainer: config.ImageContainer{Image: "alpine:latest"}, + } + options := provider2.CLIOptions{ + WorkspaceMountConsistency: "", + } + + _, ctx, err := s.runner.substitute(options, rawConfig) + + s.NoError(err) + s.NotContains(ctx.WorkspaceMount, "consistency=delegated") +} + func ptr(s string) *string { return &s } diff --git a/pkg/devcontainer/run.go b/pkg/devcontainer/run.go index b305f3c76..51e89bf91 100644 --- a/pkg/devcontainer/run.go +++ b/pkg/devcontainer/run.go @@ -322,6 +322,22 @@ func mountHasConsistency(mount string) bool { return false } +func mountSetConsistency(mount, value string) string { + quoted := "consistency='" + value + "'" + var parts []string + for part := range strings.SplitSeq(mount, ",") { + if strings.HasPrefix(part, "consistency=") { + parts = append(parts, quoted) + } else { + parts = append(parts, part) + } + } + if !mountHasConsistency(mount) { + parts = append(parts, quoted) + } + return strings.Join(parts, ",") +} + func needsDefaultConsistency() bool { return runtime.GOOS != "linux" } diff --git a/pkg/devcontainer/run_test.go b/pkg/devcontainer/run_test.go index 13126ea64..4f16cc7fc 100644 --- a/pkg/devcontainer/run_test.go +++ b/pkg/devcontainer/run_test.go @@ -235,3 +235,47 @@ func TestMountHasConsistency(t *testing.T) { } } } + +func TestMountSetConsistency(t *testing.T) { + const testConsistency = "delegated" + wantSuffix := "consistency='" + testConsistency + "'" + tests := []struct { + name string + mount string + value string + want string + }{ + { + name: "appends when no consistency present", + mount: "type=bind,source=/s,target=/t", + value: testConsistency, + want: "type=bind,source=/s,target=/t," + wantSuffix, + }, + { + name: "replaces existing single-quoted default", + mount: "type=bind,source=/s,target=/t,consistency='consistent'", + value: testConsistency, + want: "type=bind,source=/s,target=/t," + wantSuffix, + }, + { + name: "replaces existing unquoted value", + mount: "type=bind,source=/s,target=/t,consistency=cached", + value: testConsistency, + want: "type=bind,source=/s,target=/t," + wantSuffix, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := mountSetConsistency(tt.mount, tt.value) + if got != tt.want { + t.Errorf( + "mountSetConsistency(%q, %q) =\n %q\nwant:\n %q", + tt.mount, + tt.value, + got, + tt.want, + ) + } + }) + } +} diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index cebab0f49..7345c662a 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -238,6 +238,7 @@ type CLIOptions struct { GidMap []string `json:"gidMap,omitempty"` IDLabels []string `json:"idLabels,omitempty"` GPUAvailability string `json:"gpuAvailability,omitempty"` + WorkspaceMountConsistency string `json:"workspaceMountConsistency,omitempty"` // dotfiles options DotfilesRepo string `json:"dotfilesRepo,omitempty"`