Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions cmd/up/up_flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
80 changes: 79 additions & 1 deletion cmd/up/up_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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)
}
})
}
}
16 changes: 16 additions & 0 deletions cmd/up/up_validate.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -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
Expand Down
31 changes: 31 additions & 0 deletions e2e/tests/up/provider_docker.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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",
Expand Down
6 changes: 6 additions & 0 deletions pkg/devcontainer/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
64 changes: 64 additions & 0 deletions pkg/devcontainer/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
1 change: 1 addition & 0 deletions pkg/provider/workspace.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"`
Expand Down
Loading