diff --git a/cmd/executor/internal/config/BUILD.bazel b/cmd/executor/internal/config/BUILD.bazel index ef43f5e8f5d8..8a7cf2c702e5 100644 --- a/cmd/executor/internal/config/BUILD.bazel +++ b/cmd/executor/internal/config/BUILD.bazel @@ -12,6 +12,7 @@ go_library( visibility = ["//cmd/executor:__subpackages__"], deps = [ "//internal/conf/confdefaults", + "//internal/docker", "//internal/env", "//internal/executor/types", "//internal/hostname", @@ -34,6 +35,7 @@ go_test( ], deps = [ ":config", + "//internal/docker", "//internal/env", "//lib/errors", "@com_github_stretchr_testify//assert", diff --git a/cmd/executor/internal/config/config.go b/cmd/executor/internal/config/config.go index a8f5f2d60123..36fef98aa08a 100644 --- a/cmd/executor/internal/config/config.go +++ b/cmd/executor/internal/config/config.go @@ -16,6 +16,7 @@ import ( "k8s.io/utils/strings/slices" "github.com/sourcegraph/sourcegraph/internal/conf/confdefaults" + "github.com/sourcegraph/sourcegraph/internal/docker" "github.com/sourcegraph/sourcegraph/internal/env" "github.com/sourcegraph/sourcegraph/internal/executor/types" "github.com/sourcegraph/sourcegraph/internal/hostname" @@ -55,6 +56,8 @@ type Config struct { DockerRegistryMirrorURL string DockerAddHostGateway bool DockerAuthConfig types.DockerAuthConfig + DockerAdditionalMounts []string + DockerAdditionalMountsStr string KubernetesConfigPath string KubernetesNodeName string KubernetesNodeSelector string @@ -142,6 +145,7 @@ func (c *Config) Load() { c.NumTotalJobs = c.GetInt("EXECUTOR_NUM_TOTAL_JOBS", "0", "The maximum number of jobs that will be dequeued by the worker.") c.NodeExporterURL = c.GetOptional("NODE_EXPORTER_URL", "The URL of the node_exporter instance, without the /metrics path.") c.DockerRegistryNodeExporterURL = c.GetOptional("DOCKER_REGISTRY_NODE_EXPORTER_URL", "The URL of the Docker Registry instance's node_exporter, without the /metrics path.") + c.DockerAdditionalMountsStr = c.GetOptional("EXECUTOR_DOCKER_ADDITIONAL_MOUNTS", "Attach filesystem mounts to the container (e.g. type=bind,source=/foo,destination=/bar), semicolon-separated") c.MaxActiveTime = c.GetInterval("EXECUTOR_MAX_ACTIVE_TIME", "0", "The maximum time that can be spent by the worker dequeueing records to be handled.") c.DockerRegistryMirrorURL = c.GetOptional("EXECUTOR_DOCKER_REGISTRY_MIRROR_URL", "The address of a docker registry mirror to use in firecracker VMs. Supports multiple values, separated with a comma.") c.KubernetesConfigPath = c.GetOptional("EXECUTOR_KUBERNETES_CONFIG_PATH", "The path to the Kubernetes config file.") @@ -184,6 +188,10 @@ func (c *Config) Load() { c.dockerAuthConfigUnmarshalError = json.Unmarshal([]byte(c.dockerAuthConfigStr), &c.DockerAuthConfig) } + if c.DockerAdditionalMountsStr != "" { + c.DockerAdditionalMounts = strings.Split(c.DockerAdditionalMountsStr, ";") + } + if c.kubernetesNodeRequiredAffinityMatchExpressions != "" { c.kubernetesNodeRequiredAffinityMatchExpressionsUnmarshalError = json.Unmarshal([]byte(c.kubernetesNodeRequiredAffinityMatchExpressions), &c.KubernetesNodeRequiredAffinityMatchExpressions) } @@ -261,6 +269,29 @@ func (c *Config) Validate() error { c.AddError(errors.Wrap(c.dockerAuthConfigUnmarshalError, "invalid EXECUTOR_DOCKER_AUTH_CONFIG, failed to parse")) } + if c.DockerAdditionalMountsStr != "" { + // Target is a mandatory field so we can rely on it showing up if + // multiple mounts are present, but we need to check for all forms. + countTargets := func(text string, patterns ...string) int { + count := 0 + for _, pattern := range patterns { + count += strings.Count(text, pattern) + } + return count + } + + if len(c.DockerAdditionalMounts) == 1 && countTargets(c.DockerAdditionalMountsStr, "target", "dst", "destination") > 1 { + c.AddError(errors.New("invalid EXECUTOR_DOCKER_ADDITIONAL_MOUNTS, failed to parse due to incorrect separator")) + } + + for _, mount := range c.DockerAdditionalMounts { + _, err := docker.ParseMount(mount) + if err != nil { + c.AddError(errors.Wrap(err, "invalid EXECUTOR_DOCKER_ADDITIONAL_MOUNTS, failed to parse mount spec")) + } + } + } + if c.kubernetesNodeRequiredAffinityMatchExpressionsUnmarshalError != nil { c.AddError(errors.Wrap(c.kubernetesNodeRequiredAffinityMatchExpressionsUnmarshalError, "invalid EXECUTOR_KUBERNETES_NODE_REQUIRED_AFFINITY_MATCH_EXPRESSIONS, failed to parse")) } diff --git a/cmd/executor/internal/config/config_test.go b/cmd/executor/internal/config/config_test.go index 5e0a599c841b..4591bde2af9b 100644 --- a/cmd/executor/internal/config/config_test.go +++ b/cmd/executor/internal/config/config_test.go @@ -94,6 +94,7 @@ func TestConfig_Load(t *testing.T) { assert.Equal(t, "EXECUTOR_VM_PREFIX", cfg.VMPrefix) assert.True(t, cfg.KeepWorkspaces) assert.Equal(t, "EXECUTOR_DOCKER_HOST_MOUNT_PATH", cfg.DockerHostMountPath) + assert.Equal(t, "EXECUTOR_DOCKER_ADDITIONAL_MOUNTS", cfg.DockerAdditionalMountsStr) assert.Equal(t, 8, cfg.JobNumCPUs) assert.Equal(t, "EXECUTOR_JOB_MEMORY", cfg.JobMemory) assert.Equal(t, "EXECUTOR_FIRECRACKER_DISK_SPACE", cfg.FirecrackerDiskSpace) @@ -203,6 +204,7 @@ func TestConfig_Load_Defaults(t *testing.T) { assert.Equal(t, "executor", cfg.VMPrefix) assert.False(t, cfg.KeepWorkspaces) assert.Empty(t, cfg.DockerHostMountPath) + assert.Empty(t, cfg.DockerAdditionalMountsStr) assert.Equal(t, 4, cfg.JobNumCPUs) assert.Equal(t, "12G", cfg.JobMemory) assert.Equal(t, "20G", cfg.FirecrackerDiskSpace) @@ -253,6 +255,8 @@ func TestConfig_Validate(t *testing.T) { switch name { case "EXECUTOR_QUEUE_NAME": return "batches" + case "EXECUTOR_DOCKER_ADDITIONAL_MOUNTS": + return "type=bind,source=/foo,target=/bar" case "EXECUTOR_FRONTEND_URL": return "http://some-url.com" case "EXECUTOR_FRONTEND_PASSWORD": @@ -348,6 +352,59 @@ func TestConfig_Validate(t *testing.T) { }, expectedErr: errors.New("EXECUTOR_QUEUE_NAMES contains invalid queue name 'batches;codeintel', valid names are 'batches, codeintel' and should be comma-separated"), }, + { + name: "EXECUTOR_DOCKER_ADDITIONAL_MOUNTS using invalid separator", + getterFunc: func(name, defaultValue, description string) string { + switch name { + case "EXECUTOR_QUEUE_NAME": + return "batches" + case "EXECUTOR_DOCKER_ADDITIONAL_MOUNTS": + return "type=bind,source=/foo,target=/bar:type=volume,source=gomodcache,target=/gomodcache" + case "EXECUTOR_FRONTEND_URL": + return "http://some-url.com" + case "EXECUTOR_FRONTEND_PASSWORD": + return "some-password" + default: + return defaultValue + } + }, + expectedErr: errors.New("invalid EXECUTOR_DOCKER_ADDITIONAL_MOUNTS, failed to parse due to incorrect separator"), + }, + { + name: "EXECUTOR_DOCKER_ADDITIONAL_MOUNTS using incorrect format", + getterFunc: func(name, defaultValue, description string) string { + switch name { + case "EXECUTOR_QUEUE_NAME": + return "batches" + case "EXECUTOR_DOCKER_ADDITIONAL_MOUNTS": + return "source=/foo;/bar" + case "EXECUTOR_FRONTEND_URL": + return "http://some-url.com" + case "EXECUTOR_FRONTEND_PASSWORD": + return "some-password" + default: + return defaultValue + } + }, + expectedErr: errors.New("2 errors occurred:\n\t* invalid EXECUTOR_DOCKER_ADDITIONAL_MOUNTS, failed to parse mount spec: target is required\n\t* invalid EXECUTOR_DOCKER_ADDITIONAL_MOUNTS, failed to parse mount spec: invalid field '/bar' must be a key=value pair"), + }, + { + name: "EXECUTOR_DOCKER_ADDITIONAL_MOUNTS using volume options", + getterFunc: func(name, defaultValue, description string) string { + switch name { + case "EXECUTOR_QUEUE_NAME": + return "batches" + case "EXECUTOR_DOCKER_ADDITIONAL_MOUNTS": + return "type=volume,source=sshvolume,target=/app,volume-opt=sshcmd=test@node2:/home/test,volume-opt=password=testpassword" + case "EXECUTOR_FRONTEND_URL": + return "http://some-url.com" + case "EXECUTOR_FRONTEND_PASSWORD": + return "some-password" + default: + return defaultValue + } + }, + }, } for _, test := range tests { t.Run(test.name, func(t *testing.T) { diff --git a/cmd/executor/internal/run/BUILD.bazel b/cmd/executor/internal/run/BUILD.bazel index f6019e1b1270..110acda1af23 100644 --- a/cmd/executor/internal/run/BUILD.bazel +++ b/cmd/executor/internal/run/BUILD.bazel @@ -27,6 +27,7 @@ go_library( "//cmd/executor/internal/worker/runner", "//cmd/executor/internal/worker/workspace", "//internal/conf/deploy", + "//internal/docker", "//internal/download", "//internal/executor", "//internal/executor/types", diff --git a/cmd/executor/internal/run/util.go b/cmd/executor/internal/run/util.go index 8cac2cb62e72..df3e4388e767 100644 --- a/cmd/executor/internal/run/util.go +++ b/cmd/executor/internal/run/util.go @@ -20,6 +20,7 @@ import ( "github.com/sourcegraph/sourcegraph/cmd/executor/internal/worker/command" "github.com/sourcegraph/sourcegraph/cmd/executor/internal/worker/runner" "github.com/sourcegraph/sourcegraph/internal/conf/deploy" + "github.com/sourcegraph/sourcegraph/internal/docker" executorutil "github.com/sourcegraph/sourcegraph/internal/executor/util" "github.com/sourcegraph/sourcegraph/internal/observation" "github.com/sourcegraph/sourcegraph/internal/version" @@ -107,9 +108,23 @@ func dockerOptions(c *config.Config) command.DockerOptions { DockerAuthConfig: c.DockerAuthConfig, AddHostGateway: c.DockerAddHostGateway, Resources: resourceOptions(c), + Mounts: dockerAdditionalMounts(c), } } +func dockerAdditionalMounts(c *config.Config) []docker.MountOptions { + opts := make([]docker.MountOptions, 0) + + for _, mount := range c.DockerAdditionalMounts { + // No need to check for parsing errors here. We're already doing that + // during the config validation phase. + m, _ := docker.ParseMount(mount) + opts = append(opts, *m) + } + + return opts +} + func firecrackerOptions(c *config.Config) runner.FirecrackerOptions { var dockerMirrors []string if len(c.DockerRegistryMirrorURL) > 0 { diff --git a/cmd/executor/internal/worker/command/BUILD.bazel b/cmd/executor/internal/worker/command/BUILD.bazel index bf445f6da3da..df5062285951 100644 --- a/cmd/executor/internal/worker/command/BUILD.bazel +++ b/cmd/executor/internal/worker/command/BUILD.bazel @@ -18,6 +18,7 @@ go_library( "//cmd/executor/internal/util", "//cmd/executor/internal/worker/cmdlogger", "//cmd/executor/internal/worker/files", + "//internal/docker", "//internal/executor/types", "//internal/metrics", "//internal/observation", diff --git a/cmd/executor/internal/worker/command/docker.go b/cmd/executor/internal/worker/command/docker.go index d56972c92f47..4013aa140580 100644 --- a/cmd/executor/internal/worker/command/docker.go +++ b/cmd/executor/internal/worker/command/docker.go @@ -6,6 +6,7 @@ import ( "strconv" "github.com/sourcegraph/sourcegraph/cmd/executor/internal/worker/files" + "github.com/sourcegraph/sourcegraph/internal/docker" "github.com/sourcegraph/sourcegraph/internal/executor/types" ) @@ -15,6 +16,7 @@ type DockerOptions struct { ConfigPath string AddHostGateway bool Resources ResourceOptions + Mounts []docker.MountOptions } // ResourceOptions are the resource limits that can be applied to a container or VM. @@ -80,6 +82,7 @@ func formatDockerCommand(hostDir string, image string, scriptPath string, spec S "--rm", dockerHostGatewayFlag(options.AddHostGateway), dockerResourceFlags(options.Resources), + dockerMountFlags(options.Mounts), dockerVolumeFlags(hostDir), dockerWorkingDirectoryFlags(spec.Dir), dockerEnvFlags(spec.Env), @@ -115,6 +118,17 @@ func dockerResourceFlags(options ResourceOptions) []string { return flags } +func dockerMountFlags(options []docker.MountOptions) []string { + mounts := make([]string, 0) + + for _, option := range options { + mounts = append(mounts, "--mount") + mounts = append(mounts, option.String()) + } + + return mounts +} + func dockerVolumeFlags(wd string) []string { return []string{"-v", wd + ":/data"} } diff --git a/cmd/executor/internal/worker/command/docker_test.go b/cmd/executor/internal/worker/command/docker_test.go index 8dbb38e967da..c67697b69121 100644 --- a/cmd/executor/internal/worker/command/docker_test.go +++ b/cmd/executor/internal/worker/command/docker_test.go @@ -6,6 +6,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/sourcegraph/sourcegraph/cmd/executor/internal/worker/command" + "github.com/sourcegraph/sourcegraph/internal/docker" ) func TestNewDockerSpec(t *testing.T) { @@ -83,6 +84,175 @@ func TestNewDockerSpec(t *testing.T) { }, }, }, + { + name: "Docker Bind Mount", + workingDir: "/workingDirectory", + image: "some-image", + scriptPath: "some/path", + spec: command.Spec{ + Key: "some-key", + Command: []string{"some", "command"}, + Dir: "/some/dir", + Env: []string{"FOO=BAR"}, + }, + options: command.DockerOptions{ + Mounts: []docker.MountOptions{ + { + Type: docker.MountTypeBind, + Source: "/foo", + Target: "/bar", + }, + }, + }, + expectedSpec: command.Spec{ + Key: "some-key", + Command: []string{ + "docker", + "run", + "--rm", + "--mount", + "type=bind,source=/foo,target=/bar", + "-v", + "/workingDirectory:/data", + "-w", + "/data/some/dir", + "-e", + "FOO=BAR", + "--entrypoint", + "/bin/sh", + "some-image", + "/data/.sourcegraph-executor/some/path", + }, + }, + }, + { + name: "Docker Volume Mount", + workingDir: "/workingDirectory", + image: "some-image", + scriptPath: "some/path", + spec: command.Spec{ + Key: "some-key", + Command: []string{"some", "command"}, + Dir: "/some/dir", + Env: []string{"FOO=BAR"}, + }, + options: command.DockerOptions{ + Mounts: []docker.MountOptions{ + { + Type: docker.MountTypeVolume, + Source: "foo", + Target: "/bar", + }, + }, + }, + expectedSpec: command.Spec{ + Key: "some-key", + Command: []string{ + "docker", + "run", + "--rm", + "--mount", + "type=volume,source=foo,target=/bar", + "-v", + "/workingDirectory:/data", + "-w", + "/data/some/dir", + "-e", + "FOO=BAR", + "--entrypoint", + "/bin/sh", + "some-image", + "/data/.sourcegraph-executor/some/path", + }, + }, + }, + { + name: "Docker Tmpfs Mount", + workingDir: "/workingDirectory", + image: "some-image", + scriptPath: "some/path", + spec: command.Spec{ + Key: "some-key", + Command: []string{"some", "command"}, + Dir: "/some/dir", + Env: []string{"FOO=BAR"}, + }, + options: command.DockerOptions{ + Mounts: []docker.MountOptions{ + { + Type: docker.MountTypeTmpfs, + Target: "/tmp", + }, + }, + }, + expectedSpec: command.Spec{ + Key: "some-key", + Command: []string{ + "docker", + "run", + "--rm", + "--mount", + "type=tmpfs,target=/tmp", + "-v", + "/workingDirectory:/data", + "-w", + "/data/some/dir", + "-e", + "FOO=BAR", + "--entrypoint", + "/bin/sh", + "some-image", + "/data/.sourcegraph-executor/some/path", + }, + }, + }, + { + name: "Docker Multiple Mount", + workingDir: "/workingDirectory", + image: "some-image", + scriptPath: "some/path", + spec: command.Spec{ + Key: "some-key", + Command: []string{"some", "command"}, + Dir: "/some/dir", + Env: []string{"FOO=BAR"}, + }, + options: command.DockerOptions{ + Mounts: []docker.MountOptions{ + { + Type: docker.MountTypeVolume, + Source: "gomodcache", + Target: "/gomodcache", + }, + { + Type: docker.MountTypeTmpfs, + Target: "/tmp", + }, + }, + }, + expectedSpec: command.Spec{ + Key: "some-key", + Command: []string{ + "docker", + "run", + "--rm", + "--mount", + "type=volume,source=gomodcache,target=/gomodcache", + "--mount", + "type=tmpfs,target=/tmp", + "-v", + "/workingDirectory:/data", + "-w", + "/data/some/dir", + "-e", + "FOO=BAR", + "--entrypoint", + "/bin/sh", + "some-image", + "/data/.sourcegraph-executor/some/path", + }, + }, + }, { name: "Config Path", workingDir: "/workingDirectory", diff --git a/internal/docker/BUILD.bazel b/internal/docker/BUILD.bazel new file mode 100644 index 000000000000..4b1268d910d5 --- /dev/null +++ b/internal/docker/BUILD.bazel @@ -0,0 +1,24 @@ +load("//dev:go_defs.bzl", "go_test") +load("@io_bazel_rules_go//go:def.bzl", "go_library") + +go_library( + name = "docker", + srcs = [ + "mount.go", + ], + importpath = "github.com/sourcegraph/sourcegraph/internal/docker", + visibility = ["//:__subpackages__"], + deps = [ + "//lib/errors", + "@com_github_docker_docker//api/types/mount", + "@com_github_docker_go_units//:go-units", + ], +) + +go_test( + name = "mount_test", + timeout = "short", + srcs = ["mount_test.go"], + embed = [":docker"], + deps = ["@com_github_stretchr_testify//assert"], +) diff --git a/internal/docker/mount.go b/internal/docker/mount.go new file mode 100644 index 000000000000..558cfbb25d82 --- /dev/null +++ b/internal/docker/mount.go @@ -0,0 +1,246 @@ +package docker + +import ( + "encoding/csv" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + mounttypes "github.com/docker/docker/api/types/mount" + "github.com/docker/go-units" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +// Type constants. +const ( + // TypeBind is the type for mounting host dir. + MountTypeBind mounttypes.Type = "bind" + // TypeVolume is the type for remote storage volumes. + MountTypeVolume mounttypes.Type = "volume" + // TypeTmpfs is the type for mounting tmpfs. + MountTypeTmpfs mounttypes.Type = "tmpfs" +) + +type MountOptions mounttypes.Mount + +func (m MountOptions) String() string { + var sb strings.Builder + + sb.WriteString("type=") + sb.WriteString(string(m.Type)) + + if m.Source != "" { + sb.WriteString(",source=") + sb.WriteString(m.Source) + } + + sb.WriteString(",target=") + sb.WriteString(m.Target) + + if m.ReadOnly { + sb.WriteString(",readonly") + } + + if m.BindOptions != nil { + switch { + case m.Consistency != "": + sb.WriteString(",consistency=") + sb.WriteString(string(m.Consistency)) + case m.BindOptions.Propagation != "": + sb.WriteString(",bind-propagation=") + sb.WriteString(string(m.BindOptions.Propagation)) + case m.BindOptions.NonRecursive: + sb.WriteString(",bind-nonrecursive") + } + } + + if m.VolumeOptions != nil { + switch { + case m.VolumeOptions.NoCopy: + sb.WriteString(",volume-nocopy") + case m.VolumeOptions.Labels != nil: + sb.WriteString(",volume-label=") + for k, v := range m.VolumeOptions.Labels { + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(v) + } + case m.VolumeOptions.DriverConfig != nil: + sb.WriteString(",volume-driver=") + sb.WriteString(m.VolumeOptions.DriverConfig.Name) + if len(m.VolumeOptions.DriverConfig.Options) > 0 { + sb.WriteString(",volume-opt=") + for k, v := range m.VolumeOptions.DriverConfig.Options { + sb.WriteString(k) + sb.WriteString("=") + sb.WriteString(v) + } + } + } + } + + if m.TmpfsOptions != nil { + switch { + case m.TmpfsOptions.SizeBytes > 0: + sb.WriteString(",tmpfs-size=") + sb.WriteString(strconv.Itoa(int(m.TmpfsOptions.SizeBytes))) + case m.TmpfsOptions.Mode.String() != "": + sb.WriteString(",tmpfs-mode=") + sb.WriteString(fmt.Sprintf("%04o\n", m.TmpfsOptions.Mode.Perm())) + } + } + + return sb.String() +} + +// ParseMount parses a mount spec like you'd see using the Docker command-line. +// e.g. docker run --rm --mount type=bind,source=$(pwd),destination=/workspace go:latest +// Copied from https://github.com/docker/cli/blob/v24.0.6/opts/mount.go#L23 +func ParseMount(spec string) (*MountOptions, error) { + mount := &MountOptions{} + + csvReader := csv.NewReader(strings.NewReader(spec)) + fields, err := csvReader.Read() + if err != nil { + return nil, err + } + + volumeOptions := func() *mounttypes.VolumeOptions { + if mount.VolumeOptions == nil { + mount.VolumeOptions = &mounttypes.VolumeOptions{ + Labels: make(map[string]string), + } + } + if mount.VolumeOptions.DriverConfig == nil { + mount.VolumeOptions.DriverConfig = &mounttypes.Driver{} + } + return mount.VolumeOptions + } + + bindOptions := func() *mounttypes.BindOptions { + if mount.BindOptions == nil { + mount.BindOptions = new(mounttypes.BindOptions) + } + return mount.BindOptions + } + + tmpfsOptions := func() *mounttypes.TmpfsOptions { + if mount.TmpfsOptions == nil { + mount.TmpfsOptions = new(mounttypes.TmpfsOptions) + } + return mount.TmpfsOptions + } + + setValueOnMap := func(target map[string]string, value string) { + k, v, _ := strings.Cut(value, "=") + if k != "" { + target[k] = v + } + } + + mount.Type = mounttypes.TypeVolume // default to volume mounts + // Set writable as the default + for _, field := range fields { + key, val, ok := strings.Cut(field, "=") + + // TODO(thaJeztah): these options should not be case-insensitive. + key = strings.ToLower(key) + + if !ok { + switch key { + case "readonly", "ro": + mount.ReadOnly = true + continue + case "volume-nocopy": + volumeOptions().NoCopy = true + continue + case "bind-nonrecursive": + bindOptions().NonRecursive = true + continue + default: + return nil, errors.Newf("invalid field '%s' must be a key=value pair", field) + } + } + + switch key { + case "type": + mount.Type = mounttypes.Type(strings.ToLower(val)) + case "source", "src": + mount.Source = val + if strings.HasPrefix(val, "."+string(filepath.Separator)) || val == "." { + if abs, err := filepath.Abs(val); err == nil { + mount.Source = abs + } + } + case "target", "dst", "destination": + mount.Target = val + case "readonly", "ro": + mount.ReadOnly, err = strconv.ParseBool(val) + if err != nil { + return nil, errors.Newf("invalid value for %s: %s", key, val) + } + case "consistency": + mount.Consistency = mounttypes.Consistency(strings.ToLower(val)) + case "bind-propagation": + bindOptions().Propagation = mounttypes.Propagation(strings.ToLower(val)) + case "bind-nonrecursive": + bindOptions().NonRecursive, err = strconv.ParseBool(val) + if err != nil { + return nil, errors.Newf("invalid value for %s: %s", key, val) + } + case "volume-nocopy": + volumeOptions().NoCopy, err = strconv.ParseBool(val) + if err != nil { + return nil, errors.Newf("invalid value for volume-nocopy: %s", val) + } + case "volume-label": + setValueOnMap(volumeOptions().Labels, val) + case "volume-driver": + volumeOptions().DriverConfig.Name = val + case "volume-opt": + if volumeOptions().DriverConfig.Options == nil { + volumeOptions().DriverConfig.Options = make(map[string]string) + } + setValueOnMap(volumeOptions().DriverConfig.Options, val) + case "tmpfs-size": + sizeBytes, err := units.RAMInBytes(val) + if err != nil { + return nil, errors.Newf("invalid value for %s: %s", key, val) + } + tmpfsOptions().SizeBytes = sizeBytes + case "tmpfs-mode": + ui64, err := strconv.ParseUint(val, 8, 32) + if err != nil { + return nil, errors.Newf("invalid value for %s: %s", key, val) + } + tmpfsOptions().Mode = os.FileMode(ui64) + default: + return nil, errors.Newf("unexpected key '%s' in '%s'", key, field) + } + } + + if mount.Type == "" { + return nil, errors.New("type is required") + } + + if mount.Target == "" { + return nil, errors.New("target is required") + } + + if mount.VolumeOptions != nil && mount.Type != mounttypes.TypeVolume { + return nil, errors.Newf("cannot mix 'volume-*' options with mount type '%s'", mount.Type) + } + + if mount.BindOptions != nil && mount.Type != mounttypes.TypeBind { + return nil, errors.Newf("cannot mix 'bind-*' options with mount type '%s'", mount.Type) + } + + if mount.TmpfsOptions != nil && mount.Type != mounttypes.TypeTmpfs { + return nil, errors.Newf("cannot mix 'tmpfs-*' options with mount type '%s'", mount.Type) + } + + return mount, nil +} diff --git a/internal/docker/mount_test.go b/internal/docker/mount_test.go new file mode 100644 index 000000000000..6beac09623cc --- /dev/null +++ b/internal/docker/mount_test.go @@ -0,0 +1,133 @@ +package docker + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/sourcegraph/sourcegraph/lib/errors" +) + +func TestParseMount(t *testing.T) { + tests := []struct { + name string + input string + want *MountOptions + expectedErr error + }{ + { + name: "Valid bind mount", + input: "type=bind,source=/foo,target=/bar", + want: &MountOptions{ + Type: MountTypeBind, + Source: "/foo", + Target: "/bar", + }, + }, + { + name: "Valid readonly bind mount", + input: "type=bind,source=/foo,target=/bar,readonly", + want: &MountOptions{ + Type: MountTypeBind, + Source: "/foo", + Target: "/bar", + ReadOnly: true, + }, + }, + { + name: "Valid volume mount", + input: "type=volume,source=foo,target=/bar", + want: &MountOptions{ + Type: MountTypeVolume, + Source: "foo", + Target: "/bar", + }, + }, + { + name: "Valid tmpfs mount", + input: "type=tmpfs,target=/bar", + want: &MountOptions{ + Type: MountTypeTmpfs, + Target: "/bar", + }, + }, + { + name: "Invalid field", + input: "type=bind,source=/foo,target=/bar,baz", + expectedErr: errors.New("invalid field 'baz' must be a key=value pair"), + }, + { + name: "Invalid value for readonly field", + input: "type=bind,source=/foo,target=/bar,readonly=baz", + expectedErr: errors.New("invalid value for readonly: baz"), + }, + { + name: "Invalid value for bind-nonrecursive field", + input: "type=bind,source=/foo,target=/bar,bind-nonrecursive=baz", + expectedErr: errors.New("invalid value for bind-nonrecursive: baz"), + }, + { + name: "Invalid value for volume-nocopy field", + input: "type=bind,source=/foo,target=/bar,volume-nocopy=baz", + expectedErr: errors.New("invalid value for volume-nocopy: baz"), + }, + { + name: "Invalid value for tmpfs-size field", + input: "type=tmpfs,target=/bar,tmpfs-size=baz", + expectedErr: errors.New("invalid value for tmpfs-size: baz"), + }, + { + name: "Invalid value for tmpfs-mode field", + input: "type=tmpfs,target=/bar,tmpfs-mode=baz", + expectedErr: errors.New("invalid value for tmpfs-mode: baz"), + }, + { + name: "Invalid key", + input: "foo=baz", + expectedErr: errors.New("unexpected key 'foo' in 'foo=baz'"), + }, + { + name: "Missing target", + input: "source=/foo", + expectedErr: errors.New("target is required"), + }, + { + name: "Cannot mix volume options with other mount types", + input: "type=bind,source=/foo,destination=/bar,volume-nocopy", + expectedErr: errors.New("cannot mix 'volume-*' options with mount type 'bind'"), + }, + { + name: "Cannot mix bind options with other mount types", + input: "type=volume,source=foo,destination=/bar,bind-nonrecursive", + expectedErr: errors.New("cannot mix 'bind-*' options with mount type 'volume'"), + }, + { + name: "Cannot mix tmpfs options with other mount types", + input: "type=volume,source=/foo,destination=/bar,tmpfs-size=42", + expectedErr: errors.New("cannot mix 'tmpfs-*' options with mount type 'volume'"), + }, + { + name: "Cannot use ssh volume opt because it contains a colon", + input: "type=volume,source=sshvolume,target=/app,volume-opt=sshcmd=test@node2:/home/test,volume-opt=password=testpassword", + want: &MountOptions{ + Type: MountTypeVolume, + Source: "sshvolume", + Target: "/app", + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + mount, err := ParseMount(test.input) + if test.expectedErr != nil { + require.Error(t, err) + assert.EqualError(t, err, test.expectedErr.Error()) + } else { + require.NoError(t, err) + } + assert.Equal(t, test.want, mount) + }) + } +}