diff --git a/cmd/up.go b/cmd/up.go index bd9be3922..0972a4986 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -164,6 +164,10 @@ func (cmd *UpCmd) registerDevContainerFlags(upCmd *cobra.Command) { upCmd.Flags(). StringVar(&cmd.AdditionalFeatures, "additional-features", "", `Additional features to apply to the dev container (JSON as per "features" section in devcontainer.json)`) + upCmd.Flags(). + StringArrayVar(&cmd.Mounts, "mount", []string{}, + "Additional mount to apply when creating the dev container. "+ + "Format type=,source=,target=[,external=]") } func (cmd *UpCmd) registerIDEFlags(upCmd *cobra.Command) { @@ -714,6 +718,7 @@ func mergeDevPodUpOptions(baseOptions *provider2.CLIOptions) error { } else if found { baseOptions.WorkspaceEnv = append(oldOptions.WorkspaceEnv, baseOptions.WorkspaceEnv...) baseOptions.InitEnv = append(oldOptions.InitEnv, baseOptions.InitEnv...) + baseOptions.Mounts = append(oldOptions.Mounts, baseOptions.Mounts...) baseOptions.PrebuildRepositories = append( oldOptions.PrebuildRepositories, baseOptions.PrebuildRepositories...) diff --git a/cmd/up_test.go b/cmd/up_test.go new file mode 100644 index 000000000..b4765e610 --- /dev/null +++ b/cmd/up_test.go @@ -0,0 +1,25 @@ +package cmd + +import ( + "testing" + + "github.com/skevetter/devpod/cmd/flags" + "github.com/stretchr/testify/require" +) + +func TestUpMountFlagIsRepeatableAndPreservesValues(t *testing.T) { + upCmd := NewUpCmd(&flags.GlobalFlags{}) + args := []string{ + "--mount", `{"type":"volume","source":"cache","target":"/cache"}`, + "--mount", `type=bind,source=/tmp/data,target=/data,readonly`, + } + + require.NoError(t, upCmd.ParseFlags(args)) + + mounts, err := upCmd.Flags().GetStringArray("mount") + require.NoError(t, err) + require.Equal(t, []string{ + `{"type":"volume","source":"cache","target":"/cache"}`, + `type=bind,source=/tmp/data,target=/data,readonly`, + }, mounts) +} diff --git a/pkg/devcontainer/compose.go b/pkg/devcontainer/compose.go index 18409428a..baf91f157 100644 --- a/pkg/devcontainer/compose.go +++ b/pkg/devcontainer/compose.go @@ -286,6 +286,9 @@ func (r *runner) runDockerCompose( if err != nil { return nil, fmt.Errorf("merge config: %w", err) } + if err := mergeCLIMounts(mergedConfig, substitutionContext, options.Mounts); err != nil { + return nil, err + } // expose the compose project name inside the container if mergedConfig.RemoteEnv == nil { @@ -479,6 +482,9 @@ func (r *runner) startContainer( if err != nil { return nil, fmt.Errorf("merge configuration: %w", err) } + if err := mergeCLIMounts(mergedConfig, substitutionContext, options.Mounts); err != nil { + return nil, err + } additionalLabels := map[string]string{ metadata.ImageMetadataLabel: extendResult.metadataLabel, diff --git a/pkg/devcontainer/config.go b/pkg/devcontainer/config.go index 1295c512c..1485cfd32 100644 --- a/pkg/devcontainer/config.go +++ b/pkg/devcontainer/config.go @@ -221,3 +221,70 @@ func (r *runner) substitute( Raw: rawParsedConfig, }, substitutionContext, nil } + +func resolveCLIMounts( + substitutionContext *config.SubstitutionContext, + mounts []string, +) ([]*config.Mount, error) { + if len(mounts) == 0 { + return nil, nil + } + + resolvedMounts := make([]*config.Mount, 0, len(mounts)) + for _, mount := range mounts { + parsedMount := &config.Mount{} + if err := json.Unmarshal([]byte(mount), parsedMount); err != nil { + parsed := config.ParseMount(mount) + parsedMount = &parsed + } + + resolvedMount := &config.Mount{} + if err := config.Substitute(substitutionContext, parsedMount, resolvedMount); err != nil { + return nil, fmt.Errorf("substitute --mount values: %w", err) + } + + resolvedMounts = append(resolvedMounts, resolvedMount) + } + + return resolvedMounts, nil +} + +func mergeCLIMounts( + mergedConfig *config.MergedDevContainerConfig, + substitutionContext *config.SubstitutionContext, + mounts []string, +) error { + resolvedMounts, err := resolveCLIMounts(substitutionContext, mounts) + if err != nil { + return err + } + if len(resolvedMounts) == 0 { + return nil + } + + cliTargets := map[string]bool{} + dedupedCLIMounts := make([]*config.Mount, 0, len(resolvedMounts)) + for i := len(resolvedMounts) - 1; i >= 0; i-- { + mount := resolvedMounts[i] + if cliTargets[mount.Target] { + continue + } + + cliTargets[mount.Target] = true + dedupedCLIMounts = append(dedupedCLIMounts, mount) + } + slices.Reverse(dedupedCLIMounts) + + filteredMounts := make([]*config.Mount, 0, len(mergedConfig.Mounts)+len(dedupedCLIMounts)) + for _, mount := range mergedConfig.Mounts { + if cliTargets[mount.Target] { + continue + } + + filteredMounts = append(filteredMounts, mount) + } + + filteredMounts = append(filteredMounts, dedupedCLIMounts...) + mergedConfig.Mounts = filteredMounts + return nil +} diff --git a/pkg/devcontainer/config/config.go b/pkg/devcontainer/config/config.go index 571092f55..46de5aaec 100644 --- a/pkg/devcontainer/config/config.go +++ b/pkg/devcontainer/config/config.go @@ -413,21 +413,24 @@ func ParseMount(str string) Mount { retMount := Mount{} splitted := strings.SplitSeq(str, ",") for split := range splitted { - splitted2 := strings.Split(split, "=") - key := splitted2[0] + key, value, ok := strings.Cut(split, "=") + if !ok { + retMount.Other = append(retMount.Other, split) + continue + } switch key { case "src", "source": - retMount.Source = splitted2[1] + retMount.Source = value case "workspaceMount": - retMount.Source = splitted2[1] + retMount.Source = value case "workspaceFolder": - retMount.Target = splitted2[1] + retMount.Target = value case "dst", "destination", "target": - retMount.Target = splitted2[1] + retMount.Target = value case "type": - retMount.Type = splitted2[1] + retMount.Type = value case "external": - retMount.External, _ = strconv.ParseBool(splitted2[1]) + retMount.External, _ = strconv.ParseBool(value) default: retMount.Other = append(retMount.Other, split) } diff --git a/pkg/devcontainer/config/mount_test.go b/pkg/devcontainer/config/mount_test.go new file mode 100644 index 000000000..11cbed738 --- /dev/null +++ b/pkg/devcontainer/config/mount_test.go @@ -0,0 +1,25 @@ +package config + +import "testing" + +func TestParseMount_PreservesEqualsInValue(t *testing.T) { + mount := ParseMount("type=volume,source=my-cache=1,target=/cache") + + if mount.Type != "volume" { + t.Fatalf("expected type volume, got %q", mount.Type) + } + if mount.Source != "my-cache=1" { + t.Fatalf("expected source my-cache=1, got %q", mount.Source) + } + if mount.Target != "/cache" { + t.Fatalf("expected target /cache, got %q", mount.Target) + } +} + +func TestParseMount_InvalidSegmentDoesNotPanic(t *testing.T) { + mount := ParseMount("type=volume,readonly,target=/cache") + + if len(mount.Other) != 1 || mount.Other[0] != "readonly" { + t.Fatalf("expected readonly in other, got %#v", mount.Other) + } +} diff --git a/pkg/devcontainer/config_test.go b/pkg/devcontainer/config_test.go index 7bf3bfb4f..c26d1544f 100644 --- a/pkg/devcontainer/config_test.go +++ b/pkg/devcontainer/config_test.go @@ -247,3 +247,76 @@ func (s *SubstituteTestSuite) TestSubstitute_AdditionalFeaturesEmpty() { s.NoError(err) s.Nil(result.Config.Features) } + +func (s *SubstituteTestSuite) TestResolveCLIMounts_SubstitutesVariables() { + substitutionContext := &config.SubstitutionContext{ + DevContainerID: "test-id", + LocalWorkspaceFolder: "/workspace", + ContainerWorkspaceFolder: "/workspaces/test-workspace", + Env: map[string]string{ + "CACHE_NAME": "my-cache=1", + }, + } + + mounts, err := resolveCLIMounts(substitutionContext, []string{ + `{"type":"volume","source":"${localEnv:CACHE_NAME}","target":"${containerWorkspaceFolder}/cache"}`, + }) + + s.NoError(err) + s.Len(mounts, 1) + s.Equal("volume", mounts[0].Type) + s.Equal("my-cache=1", mounts[0].Source) + s.Equal("/workspaces/test-workspace/cache", mounts[0].Target) +} + +func (s *SubstituteTestSuite) TestResolveCLIMounts_AcceptsRawStringForm() { + substitutionContext := &config.SubstitutionContext{ + ContainerWorkspaceFolder: "/workspaces/test-workspace", + } + + mounts, err := resolveCLIMounts(substitutionContext, []string{ + "type=bind,source=/tmp/data,target=${containerWorkspaceFolder}/data,readonly", + }) + + s.NoError(err) + s.Len(mounts, 1) + s.Equal("bind", mounts[0].Type) + s.Equal("/tmp/data", mounts[0].Source) + s.Equal("/workspaces/test-workspace/data", mounts[0].Target) + s.Equal([]string{"readonly"}, mounts[0].Other) +} + +func (s *SubstituteTestSuite) TestMergeCLIMounts_OverridesByTarget() { + mergedConfig := &config.MergedDevContainerConfig{ + NonComposeBase: config.NonComposeBase{ + Mounts: []*config.Mount{ + { + Type: "volume", + Source: "existing-cache", + Target: "/cache", + }, + { + Type: "volume", + Source: "existing-tools", + Target: "/tools", + }, + }, + }, + } + substitutionContext := &config.SubstitutionContext{ + ContainerWorkspaceFolder: "/workspaces/test-workspace", + } + + err := mergeCLIMounts(mergedConfig, substitutionContext, []string{ + `{"type":"volume","source":"cli-cache","target":"/cache"}`, + `{"type":"volume","source":"cli-data","target":"${containerWorkspaceFolder}/data"}`, + }) + + s.NoError(err) + s.Len(mergedConfig.Mounts, 3) + s.Equal("existing-tools", mergedConfig.Mounts[0].Source) + s.Equal("cli-cache", mergedConfig.Mounts[1].Source) + s.Equal("/cache", mergedConfig.Mounts[1].Target) + s.Equal("cli-data", mergedConfig.Mounts[2].Source) + s.Equal("/workspaces/test-workspace/data", mergedConfig.Mounts[2].Target) +} diff --git a/pkg/devcontainer/single.go b/pkg/devcontainer/single.go index 6bf1518fa..51f2f6199 100644 --- a/pkg/devcontainer/single.go +++ b/pkg/devcontainer/single.go @@ -186,6 +186,9 @@ func (r *runner) mergeExistingContainerConfig( if err != nil { return nil, fmt.Errorf("merge config: %w", err) } + if err := mergeCLIMounts(mergedConfig, p.substitutionContext, p.options.Mounts); err != nil { + return nil, err + } return mergedConfig, nil } @@ -241,6 +244,9 @@ func (r *runner) resolveNewContainer( if err != nil { return nil, fmt.Errorf("merge config: %w", err) } + if err := mergeCLIMounts(mergedConfig, p.substitutionContext, p.options.Mounts); err != nil { + return nil, err + } r.injectDaemonEntrypoint(p, mergedConfig) diff --git a/pkg/provider/workspace.go b/pkg/provider/workspace.go index 017bcb56a..8a6c057e5 100644 --- a/pkg/provider/workspace.go +++ b/pkg/provider/workspace.go @@ -228,6 +228,7 @@ type CLIOptions struct { SSHAuthSockID string `json:"sshAuthSockID,omitempty"` // ID to use when looking for SSH_AUTH_SOCK, defaults to a new random ID if not set (only used for browser IDEs) StrictHostKeyChecking bool `json:"strictHostKeyChecking,omitempty"` AdditionalFeatures string `json:"additionalFeatures,omitempty"` + Mounts []string `json:"mounts,omitempty"` ExtraDevContainerPath string `json:"extraDevContainerPath,omitempty"` User string `json:"user,omitempty"` Userns string `json:"userns,omitempty"`