diff --git a/cmd/up/up_flags.go b/cmd/up/up_flags.go index 98f41d525..91dcbedfc 100644 --- a/cmd/up/up_flags.go +++ b/cmd/up/up_flags.go @@ -217,6 +217,10 @@ func (cmd *UpCmd) registerWorkspaceFlags(upCmd *cobra.Command) { upCmd.Flags(). StringVar(&cmd.WorkspaceMountConsistency, "workspace-mount-consistency", "", "Consistency mode for the workspace bind mount (consistent, cached, delegated)") + upCmd.Flags(). + StringArrayVar(&cmd.Mounts, "mount", []string{}, + "Additional mount to add to the container (format: type=bind,source=/host/path,target=/container/path). "+ + "Can be specified multiple times") } func (cmd *UpCmd) registerTestingFlags(upCmd *cobra.Command) { diff --git a/cmd/up/up_test.go b/cmd/up/up_test.go index 030203f70..7821a75d7 100644 --- a/cmd/up/up_test.go +++ b/cmd/up/up_test.go @@ -8,7 +8,12 @@ import ( "github.com/stretchr/testify/require" ) -const probeNone = "none" +const ( + probeNone = "none" + flagNameMount = "mount" + flagMount = "--" + flagNameMount + testBindMountAB = "type=bind,source=/a,target=/b" +) func TestUpCmd_ValidateDefaultUserEnvProbe(t *testing.T) { tests := []struct { @@ -187,3 +192,76 @@ func TestUpCmd_RemoteUserFlagParsesValue(t *testing.T) { flag := upCmd.Flags().Lookup("remote-user") assert.Equal(t, "vscode", flag.Value.String()) } + +func TestUpCmd_MountFlag(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + flag := upCmd.Flags().Lookup(flagNameMount) + require.NotNil(t, flag) + assert.Equal(t, "[]", flag.DefValue) +} + +func TestUpCmd_MountFlagParsesValue(t *testing.T) { + const bindMount = "type=bind,source=/host/path,target=/container/path" + upCmd := NewUpCmd(&flags.GlobalFlags{}) + err := upCmd.ParseFlags([]string{flagMount, bindMount}) + require.NoError(t, err) + + flag := upCmd.Flags().Lookup(flagNameMount) + assert.Contains(t, flag.Value.String(), bindMount) +} + +func TestUpCmd_MountFlagRepeatable(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + err := upCmd.ParseFlags([]string{ + flagMount, testBindMountAB, + flagMount, "type=volume,source=myvolume,target=/c", + }) + require.NoError(t, err) + + flag := upCmd.Flags().Lookup(flagNameMount) + val := flag.Value.String() + assert.Contains(t, val, testBindMountAB) + assert.Contains(t, val, "type=volume,source=myvolume,target=/c") +} + +func TestUpCmd_ValidateMounts(t *testing.T) { + tests := []struct { + name string + mounts []string + wantErr bool + }{ + {name: "empty is valid", mounts: []string{}}, + { + name: "valid bind mount", + mounts: []string{"type=bind,source=/host,target=/container"}, + wantErr: false, + }, + { + name: "valid volume mount", + mounts: []string{"type=volume,source=vol,target=/data"}, + wantErr: false, + }, + {name: "multiple valid", mounts: []string{ + testBindMountAB, + "type=volume,source=v,target=/c", + }, wantErr: false}, + {name: "missing target", mounts: []string{"type=bind,source=/host"}, wantErr: true}, + {name: "one valid one missing target", mounts: []string{ + testBindMountAB, + "type=bind,source=/c", + }, wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cmd := &UpCmd{GlobalFlags: &flags.GlobalFlags{}} + cmd.Mounts = tt.mounts + err := cmd.validate() + if tt.wantErr { + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --mount") + } else { + assert.NoError(t, err) + } + }) + } +} diff --git a/cmd/up/up_validate.go b/cmd/up/up_validate.go index aeb1dc208..435599e65 100644 --- a/cmd/up/up_validate.go +++ b/cmd/up/up_validate.go @@ -36,6 +36,9 @@ func (cmd *UpCmd) validate() error { if err := validateWorkspaceMountConsistency(cmd.WorkspaceMountConsistency); err != nil { return err } + if err := validateMounts(cmd.Mounts); err != nil { + return err + } return validateRemoteUserUID(cmd.UpdateRemoteUserUIDDefault) } @@ -69,6 +72,19 @@ func validateWorkspaceMountConsistency(value string) error { } } +func validateMounts(mounts []string) error { + for _, m := range mounts { + parsed := config2.ParseMount(m) + if parsed.Target == "" { + return fmt.Errorf( + "invalid --mount value %q: target (dst/destination/target) is required", + m, + ) + } + } + return nil +} + func validateRemoteUserUID(value string) error { if value == "" { return nil diff --git a/e2e/tests/up/provider_docker.go b/e2e/tests/up/provider_docker.go index 4deedbb4e..e637a6173 100644 --- a/e2e/tests/up/provider_docker.go +++ b/e2e/tests/up/provider_docker.go @@ -13,6 +13,7 @@ import ( "github.com/devsy-org/devsy/e2e/framework" docker "github.com/devsy-org/devsy/pkg/docker" "github.com/docker/docker/api/types/container" + "github.com/docker/docker/api/types/mount" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -624,6 +625,36 @@ var _ = ginkgo.Describe( ginkgo.SpecTimeout(framework.TimeoutShort()), ) + ginkgo.It("CLI --mount flag creates additional mount", func(ctx context.Context) { + tempDir, err := dtc.setupAndUp( + ctx, + "tests/up/testdata/docker", + "--mount", "type=volume,source=devsy-e2e-mount-test,target=/cli-mount-test", + ) + framework.ExpectNoError(err) + + workspace, err := dtc.f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + + ids, err := dtc.findWorkspaceContainer(ctx, workspace) + framework.ExpectNoError(err) + gomega.Expect(ids).To(gomega.HaveLen(1)) + + var details []container.InspectResponse + err = dtc.dockerHelper.Inspect(ctx, ids, "container", &details) + framework.ExpectNoError(err) + + hasCLIMount := false + for _, m := range details[0].Mounts { + if m.Destination == "/cli-mount-test" { + hasCLIMount = true + gomega.Expect(m.Type).To(gomega.Equal(mount.TypeVolume)) + break + } + } + gomega.Expect(hasCLIMount).To(gomega.BeTrue()) + }, ginkgo.SpecTimeout(framework.TimeoutShort())) + ginkgo.It("secrets-file injects env into lifecycle commands", func(ctx context.Context) { tempDir, err := setupWorkspace( "tests/up/testdata/docker-secrets-file", diff --git a/pkg/devcontainer/config.go b/pkg/devcontainer/config.go index 73c181e33..6cabd99ac 100644 --- a/pkg/devcontainer/config.go +++ b/pkg/devcontainer/config.go @@ -203,6 +203,12 @@ func (r *runner) substitute( ) } + // merge additional mounts from CLI --mount flags + for _, mountStr := range options.Mounts { + m := config.ParseMount(mountStr) + parsedConfig.Mounts = append(parsedConfig.Mounts, &m) + } + if options.DevContainerImage != "" { parsedConfig.Build = nil parsedConfig.Dockerfile = "" diff --git a/pkg/devcontainer/config_test.go b/pkg/devcontainer/config_test.go index ed28b7b37..ac0511611 100644 --- a/pkg/devcontainer/config_test.go +++ b/pkg/devcontainer/config_test.go @@ -295,4 +295,68 @@ func (s *SubstituteTestSuite) TestSubstitute_WorkspaceMountConsistencyEmpty() { s.NotContains(ctx.WorkspaceMount, "consistency=delegated") } +func (s *SubstituteTestSuite) TestSubstitute_CLIMountsAppended() { + rawConfig := &config.DevContainerConfig{ + ImageContainer: config.ImageContainer{Image: "alpine:latest"}, + } + options := provider2.CLIOptions{ + Mounts: []string{ + "type=bind,source=/host/data,target=/data", + "type=volume,source=myvolume,target=/vol", + }, + } + + substitutedConfig, _, err := s.runner.substitute(options, rawConfig) + + s.NoError(err) + s.Require().Len(substitutedConfig.Config.Mounts, 2) + s.Equal("bind", substitutedConfig.Config.Mounts[0].Type) + s.Equal("/host/data", substitutedConfig.Config.Mounts[0].Source) + s.Equal("/data", substitutedConfig.Config.Mounts[0].Target) + s.Equal("volume", substitutedConfig.Config.Mounts[1].Type) + s.Equal("myvolume", substitutedConfig.Config.Mounts[1].Source) + s.Equal("/vol", substitutedConfig.Config.Mounts[1].Target) +} + +func (s *SubstituteTestSuite) TestSubstitute_CLIMountsMergedWithExisting() { + rawConfig := &config.DevContainerConfig{ + ImageContainer: config.ImageContainer{Image: "alpine:latest"}, + NonComposeBase: config.NonComposeBase{ + Mounts: []*config.Mount{ + {Type: "bind", Source: "/existing", Target: "/existing-target"}, + }, + }, + } + options := provider2.CLIOptions{ + Mounts: []string{ + "type=bind,source=/new,target=/new-target", + }, + } + + substitutedConfig, _, err := s.runner.substitute(options, rawConfig) + + s.NoError(err) + s.Require().Len(substitutedConfig.Config.Mounts, 2) + s.Equal("/existing-target", substitutedConfig.Config.Mounts[0].Target) + s.Equal("/new-target", substitutedConfig.Config.Mounts[1].Target) +} + +func (s *SubstituteTestSuite) TestSubstitute_CLIMountsEmpty() { + rawConfig := &config.DevContainerConfig{ + ImageContainer: config.ImageContainer{Image: "alpine:latest"}, + NonComposeBase: config.NonComposeBase{ + Mounts: []*config.Mount{ + {Type: "bind", Source: "/existing", Target: "/existing-target"}, + }, + }, + } + options := provider2.CLIOptions{} + + substitutedConfig, _, err := s.runner.substitute(options, rawConfig) + + s.NoError(err) + s.Require().Len(substitutedConfig.Config.Mounts, 1) + s.Equal("/existing-target", substitutedConfig.Config.Mounts[0].Target) +} + func ptr(s string) *string { return &s } diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index a6f93e5c1..96af1a469 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -240,6 +240,7 @@ type CLIOptions struct { IDLabels []string `json:"idLabels,omitempty"` GPUAvailability string `json:"gpuAvailability,omitempty"` WorkspaceMountConsistency string `json:"workspaceMountConsistency,omitempty"` + Mounts []string `json:"mounts,omitempty"` UpdateRemoteUserUIDDefault string `json:"updateRemoteUserUIDDefault,omitempty"` ContainerDataFolder string `json:"containerDataFolder,omitempty"` MountWorkspaceGitRoot *bool `json:"mountWorkspaceGitRoot,omitempty"`