diff --git a/e2e/tests/up/provider_podman_machine_bind_darwin.go b/e2e/tests/up/provider_podman_machine_bind_darwin.go new file mode 100644 index 0000000000..d245d3c3d7 --- /dev/null +++ b/e2e/tests/up/provider_podman_machine_bind_darwin.go @@ -0,0 +1,323 @@ +package up + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + "unicode" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + podmanMachineBindLabel = "up-provider-podman-machine-bind" + podmanMachineCommand = "machine" + podmanJSONFormat = "json" + podmanFormatFlag = "--format" + podmanMarkerTarget = "/tmp/devsy-engine-only-marker" + podmanMarkerName = "/marker" + podmanHomeEnv = "DEVSY_HOME" +) + +type podmanMachineConnection struct { + Name string + URI string + Default bool +} + +type podmanMachineSSHConfig struct { + Port int + RemoteUsername string +} + +type podmanMachineInspection struct { + Name string + State string + SSHConfig podmanMachineSSHConfig +} + +type ( + podmanMachineName struct{ Name string } + podmanMachineSecurity struct { + Rootless bool + SELinuxEnabled bool + } + podmanMachineSocket struct{ Path string } + podmanMachineHost struct { + Security podmanMachineSecurity + Hostname string + RemoteSocket podmanMachineSocket + } +) +type podmanMachineInfo struct{ Host podmanMachineHost } + +type podmanBindFixture struct { + Image string `json:"image"` + Mounts []string `json:"mounts"` +} + +var _ = ginkgo.Describe("Podman Machine engine-side bind sources", + ginkgo.Label(podmanMachineBindLabel), func() { + ginkgo.It( + "mounts a guest-only source without creating or changing it", + func(ctx context.Context) { + quote := func(value string) string { return "'" + strings.ReplaceAll(value, "'", "'\"'\"'") + "'" } + podman, err := exec.LookPath("podman") + requirePodmanMachinePrerequisite(err == nil, "Podman CLI is not installed") + run := func(args ...string) string { + // #nosec G204 -- executable is resolved Podman; arguments are test commands and quoted owned fixture paths. + output, cmdErr := exec.CommandContext(ctx, podman, args...).CombinedOutput() + framework.ExpectNoError(cmdErr, "podman %v: %s", args, output) + return strings.TrimSpace(string(output)) + } + for _, key := range []string{ + "CONTAINER_HOST", "CONTAINER_CONNECTION", "DOCKER_HOST", "DEVSY_CONFIG", "DEVSY_PROVIDER", + } { + requirePodmanMachinePrerequisite(os.Getenv(key) == "", + key+" overrides the isolated provider or selected connection") + } + var connections []podmanMachineConnection + framework.ExpectNoError( + json.Unmarshal( + []byte( + run("system", "connection", "list", podmanFormatFlag, podmanJSONFormat), + ), + &connections, + ), + ) + selected := -1 + for i := range connections { + if connections[i].Default { + selected = i + } + } + requirePodmanMachinePrerequisite( + selected >= 0, + "no default native Podman connection is configured", + ) + connection := connections[selected] + endpoint, err := url.Parse(connection.URI) + framework.ExpectNoError(err) + gomega.Expect(endpoint.Scheme).To(gomega.Equal(cmdSSH)) + gomega.Expect(endpoint.User).NotTo(gomega.BeNil()) + + var machines []podmanMachineInspection + var machineNames []podmanMachineName + framework.ExpectNoError( + json.Unmarshal( + []byte( + run(podmanMachineCommand, "list", podmanFormatFlag, podmanJSONFormat), + ), + &machineNames, + ), + ) + inspectArgs := []string{podmanMachineCommand, "inspect"} + for _, entry := range machineNames { + inspectArgs = append(inspectArgs, entry.Name) + } + requirePodmanMachinePrerequisite( + len(machineNames) > 0, + "no Podman machines are available", + ) + framework.ExpectNoError(json.Unmarshal([]byte(run(inspectArgs...)), &machines)) + machine := "" + for _, candidate := range machines { + if fmt.Sprint(candidate.SSHConfig.Port) == endpoint.Port() && + candidate.SSHConfig.RemoteUsername == endpoint.User.Username() { + gomega.Expect(machine). + To(gomega.BeEmpty(), "connection must identify exactly one machine") + requirePodmanMachinePrerequisite(candidate.State == "running", + "selected Podman machine is not running") + machine = candidate.Name + } + } + gomega.Expect(machine). + NotTo(gomega.BeEmpty(), "selected connection must match a machine's SSH port and user") + gomega.Expect(endpoint.Hostname()). + To(gomega.BeElementOf("127.0.0.1", "localhost", "::1")) + + // Machine SSH otherwise follows machine rootful mode instead of the selected connection's user. + guestSSHArgs := []string{ + podmanMachineCommand, + cmdSSH, + "--username", + endpoint.User.Username(), + machine, + } + guest := func(script string) string { + return run(append(guestSSHArgs, "sh -c "+quote(script))...) + } + var info podmanMachineInfo + framework.ExpectNoError( + json.Unmarshal([]byte(run("info", podmanFormatFlag, podmanJSONFormat)), &info), + ) + gomega.Expect(info.Host.Security.Rootless). + To(gomega.BeTrue(), "native gate requires rootless Podman") + guestUID := guest("id -u") + gomega.Expect(guestUID).NotTo(gomega.Equal("0")) + ginkgo.GinkgoWriter.Printf( + "Guest UID: %s; runtime directory: %s\n", + guestUID, + guest(`printf %s "$XDG_RUNTIME_DIR"`), + ) + gomega.Expect(info.Host.Hostname).To(gomega.Equal(guest("hostname"))) + gomega.Expect(endpoint.Path). + To(gomega.Equal(strings.TrimPrefix(info.Host.RemoteSocket.Path, "unix://"))) + gomega.Expect(guest(`id -un`)).To(gomega.Equal(endpoint.User.Username())) + ginkgo.GinkgoWriter.Printf( + "Podman version: %s; selected connection: %s; machine: %s\n", + run("version", podmanFormatFlag, podmanJSONFormat), + connection.Name, + machine, + ) + + source := guest( + `set -eu; test -n "$XDG_RUNTIME_DIR"; test -d "$XDG_RUNTIME_DIR"; ` + + `mktemp -d "$XDG_RUNTIME_DIR/devsy-bind-e2e.XXXXXXXX"`, + ) + gomega.Expect(source). + To(gomega.MatchRegexp(`^/[^\r\n']+/devsy-bind-e2e\.[A-Za-z0-9]+$`)) + ginkgo.DeferCleanup(func() { + cleanupCtx, cancel := context.WithTimeout(context.Background(), time.Minute) + defer cancel() + // #nosec G204 -- resolved Podman removes only the quoted directory created by this spec. + output, cleanupErr := exec.CommandContext(cleanupCtx, podman, + append(guestSSHArgs, "sh -c "+quote("rm -rf -- "+quote(source)))...). + CombinedOutput() + framework.ExpectNoError(cleanupErr, "remove owned guest fixture: %s", output) + }) + marker := filepath.Base(source) + guest( + "printf %s " + quote( + marker, + ) + " > " + quote( + source+podmanMarkerName, + ) + "; chmod 755 " + quote( + source, + ) + "; chmod 666 " + quote( + source+podmanMarkerName, + ), + ) + snapshotFormat := "%a:%u:%g:%s:%Y" + if info.Host.Security.SELinuxEnabled { + // Label only the owned fixture so SELinux permits the unchanged readonly bind. + guest("chcon -t container_file_t -- " + quote(source+podmanMarkerName)) + snapshotFormat += ":%C" + } + + _, err = os.Stat(source + podmanMarkerName) + gomega.Expect(os.IsNotExist(err)). + To(gomega.BeTrue(), "fixture must be absent on macOS: %s", source) + gomega.Expect(guest("cat " + quote(source+podmanMarkerName))). + To(gomega.Equal(marker)) + snapshotCommand := "stat -c " + quote(snapshotFormat) + " " + quote(source) + " " + + quote(source+podmanMarkerName) + before := guest(snapshotCommand) + + initialDir, err := os.Getwd() + framework.ExpectNoError(err) + // Keep the SSH agent socket below the Darwin Unix socket path limit. + devsyHome, err := os.MkdirTemp("/tmp", "ds-") + framework.ExpectNoError(err) + ginkgo.DeferCleanup(func() { framework.ExpectNoError(os.RemoveAll(devsyHome)) }) + previousHome, hadHome := os.LookupEnv(podmanHomeEnv) + framework.ExpectNoError(os.Setenv(podmanHomeEnv, devsyHome)) + ginkgo.DeferCleanup(func() { + if hadHome { + framework.ExpectNoError(os.Setenv(podmanHomeEnv, previousHome)) + } else { + framework.ExpectNoError(os.Unsetenv(podmanHomeEnv)) + } + }) + f, err := framework.SetupDockerProvider(filepath.Join(initialDir, "bin"), podman) + framework.ExpectNoError(err) + content, err := json.Marshal(podmanBindFixture{ + Image: "ghcr.io/devsy-org/test-images/base:alpine", + Mounts: []string{ + "type=bind,source=" + source + podmanMarkerName + ",target=" + podmanMarkerTarget + ",readonly", + }, + }) + framework.ExpectNoError(err) + workspace := framework.WriteTempDevcontainer("devsy-machine-bind-", string(content)) + ginkgo.DeferCleanup(func() { framework.ExpectNoError(os.RemoveAll(workspace)) }) + ginkgo.DeferCleanup( + func(cleanupCtx context.Context) error { return f.CleanupWorkspace(cleanupCtx, workspace) }, + ) + sha, err := exec.CommandContext(ctx, "git", "rev-parse", "HEAD").Output() + framework.ExpectNoError(err) + ginkgo.GinkgoWriter.Printf("Repository SHA: %s", sha) + framework.ExpectNoError(f.DevsyUp(ctx, workspace)) + actual, err := f.DevsySSH( + ctx, + workspace, + "if printf '' >> "+podmanMarkerTarget+"; then exit 1; fi; cat "+podmanMarkerTarget, + ) + framework.ExpectNoError(err) + gomega.Expect(actual).To(gomega.Equal(marker)) + framework.ExpectNoError(f.DevsyWorkspaceDelete(ctx, workspace)) + gomega.Expect(guest("cat " + quote(source+podmanMarkerName))). + To(gomega.Equal(marker)) + gomega.Expect(guest(snapshotCommand)). + To(gomega.Equal(before)) + _, err = os.Stat(source + podmanMarkerName) + gomega.Expect(os.IsNotExist(err)).To(gomega.BeTrue()) + missingSource := source + "/missing-marker" + invalidContent := strings.ReplaceAll( + string(content), + source+podmanMarkerName, + missingSource, + ) + framework.ExpectNoError( + os.WriteFile( + filepath.Join(workspace, ".devcontainer", "devcontainer.json"), + []byte(invalidContent), + 0o600, + ), + ) + stdout, _, upErr := f.DevsyUpStreams( + ctx, + workspace, + "--result-format", + podmanJSONFormat, + ) + gomega.Expect(upErr).To(gomega.HaveOccurred()) + envelope, ok := parseFailedStatus(stdout) + gomega.Expect(ok).To(gomega.BeTrue(), "expected failed JSON envelope: %s", stdout) + gomega.Expect(envelope.Error.Message). + To(gomega.ContainSubstring("failed to start dev container")) + gomega.Expect(envelope.Error.Message).To(gomega.ContainSubstring(missingSource)) + gomega.Expect(envelope.Error.Message). + NotTo(gomega.ContainSubstring("bind mount source path does not exist")) + guest("test ! -e " + quote(missingSource)) + }, + ginkgo.SpecTimeout(framework.TimeoutLong()), + ) + }) + +func requirePodmanMachinePrerequisite(available bool, reason string) { + if available { + return + } + config, _ := ginkgo.GinkgoConfiguration() + message := "native gate BLOCKED: " + reason + if isPodmanMachineQualificationFilter(config.LabelFilter) { + ginkgo.Fail(message, 1) + } + ginkgo.Skip(message, 1) +} + +func isPodmanMachineQualificationFilter(filter string) bool { + // Ginkgo validates syntax first; only a standalone label requests native qualification. + label := strings.TrimFunc(filter, func(r rune) bool { + return r == '(' || r == ')' || unicode.IsSpace(r) + }) + return strings.ToLower(label) == podmanMachineBindLabel +} diff --git a/e2e/tests/up/provider_podman_machine_bind_darwin_test.go b/e2e/tests/up/provider_podman_machine_bind_darwin_test.go new file mode 100644 index 0000000000..03635ae33e --- /dev/null +++ b/e2e/tests/up/provider_podman_machine_bind_darwin_test.go @@ -0,0 +1,64 @@ +package up + +import ( + "strings" + "testing" + + "github.com/onsi/ginkgo/v2/types" +) + +func TestIsPodmanMachineQualificationFilter(t *testing.T) { + cases := []struct { + name string + filter string + qualification bool + }{ + {name: "unfiltered"}, + {name: "bare native", filter: podmanMachineBindLabel, qualification: true}, + {name: "nested native", filter: "((" + podmanMachineBindLabel + "))", qualification: true}, + { + name: "spaced native", + filter: " ( ( " + podmanMachineBindLabel + " ) ) ", + qualification: true, + }, + { + name: "case insensitive native", + filter: strings.ToUpper(podmanMachineBindLabel), + qualification: true, + }, + { + name: "vertical tab and form feed", + filter: "\v\f" + podmanMachineBindLabel + "\v\f", + qualification: true, + }, + { + name: "Unicode whitespace", + filter: "\u2003\u00a0" + podmanMachineBindLabel + "\u2003\u00a0", + qualification: true, + }, + { + name: "Ginkgo dotted capital I", + filter: strings.ReplaceAll(podmanMachineBindLabel, "i", "\u0130"), + qualification: true, + }, + {name: "broad exclusion", filter: "!kubernetes"}, + {name: "native exclusion", filter: "!" + podmanMachineBindLabel}, + {name: "OR includes native", filter: "up-behaviors || " + podmanMachineBindLabel}, + {name: "AND includes native", filter: podmanMachineBindLabel + " && !kubernetes"}, + { + name: "parenthesized compound", + filter: "(" + podmanMachineBindLabel + " || up-behaviors)", + }, + {name: "regex selects native", filter: "/up-provider-podman-machine-bind/"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if _, err := types.ParseLabelFilter(tc.filter); err != nil { + t.Fatalf("policy case must be valid Ginkgo syntax: %v", err) + } + if got := isPodmanMachineQualificationFilter(tc.filter); got != tc.qualification { + t.Errorf("qualification for %q = %t, want %t", tc.filter, got, tc.qualification) + } + }) + } +} diff --git a/e2e/tests/up/up_behaviors.go b/e2e/tests/up/up_behaviors.go index 4873948f8f..831465ee6e 100644 --- a/e2e/tests/up/up_behaviors.go +++ b/e2e/tests/up/up_behaviors.go @@ -13,6 +13,7 @@ import ( docker "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/flags/names" "github.com/devsy-org/devsy/pkg/status" + "github.com/docker/docker/api/types/container" "github.com/onsi/ginkgo/v2" "github.com/onsi/gomega" ) @@ -265,7 +266,25 @@ var _ = ginkgo.Describe("up command behaviors", ginkgo.Label("up-behaviors"), fu gomega.Expect(ok). To(gomega.BeTrue(), "stdout should contain a failed status envelope: %s", stdout) gomega.Expect(envelope.Error.Message). - To(gomega.ContainSubstring("bind mount source path does not exist")) + To(gomega.ContainSubstring("failed to start dev container")) + gomega.Expect(envelope.Error.Message).To(gomega.ContainSubstring("/nonexistent/path")) + gomega.Expect(envelope.Error.Message). + NotTo(gomega.ContainSubstring("bind mount source path does not exist")) + + workspace, err := dtc.f.FindWorkspace(ctx, tempDir) + framework.ExpectNoError(err) + ids, err := dtc.findWorkspaceContainer(ctx, workspace) + framework.ExpectNoError(err) + if len(ids) > 0 { + var details []container.InspectResponse + framework.ExpectNoError(dtc.dockerHelper.Inspect(ctx, ids, "container", &details)) + gomega.Expect(details).To(gomega.HaveLen(len(ids))) + for _, detail := range details { + gomega.Expect(detail.State).NotTo(gomega.BeNil()) + gomega.Expect(detail.State.Running).To(gomega.BeFalse(), + "failed up must not leave container %s running", detail.ID) + } + } }, ginkgo.SpecTimeout(framework.TimeoutShort()), ) diff --git a/pkg/driver/docker/diagnostics.go b/pkg/driver/docker/diagnostics.go index e23d887a91..ce76ce618b 100644 --- a/pkg/driver/docker/diagnostics.go +++ b/pkg/driver/docker/diagnostics.go @@ -52,13 +52,11 @@ func appendBindSrc(dst []string, spec string) []string { return dst } -// logBindSources records host-side resolution of each bind source. A -// "host=exists" line followed by docker reporting the path missing is the -// fingerprint of a Docker Desktop file-share cache race. +// logBindSources records client-side Lstat results, not daemon-side source validation. func logBindSources(args []string) { for _, src := range extractBindSources(args) { _, err := os.Lstat(src) - log.Debugf("docker bind: src=%s exists=%t", src, err == nil) + log.Debugf("docker bind: src=%s client_path_exists=%t", src, err == nil) } } diff --git a/pkg/driver/docker/lifecycle.go b/pkg/driver/docker/lifecycle.go index 9fe259c7a6..601486524f 100644 --- a/pkg/driver/docker/lifecycle.go +++ b/pkg/driver/docker/lifecycle.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "io" + "os" "strings" "time" @@ -12,6 +13,8 @@ import ( "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" "github.com/devsy-org/devsy/pkg/log" + "github.com/devsy-org/devsy/pkg/secrets" + "github.com/devsy-org/devsy/pkg/subprocess" ) const ( @@ -422,7 +425,15 @@ func (d *dockerDriver) startContainer( logHostEnvOnce(ctx, d.Docker) logBindSources(args) - err := d.Docker.RunWithDir(ctx, dir, args, docker.Streams{Stdout: writer, Stderr: writer}) + stderr := subprocess.NewBuffer( + secrets.NewEnvironmentRedactor(append(os.Environ(), d.Docker.Environment...)), + ) + err := d.Docker.RunWithDir( + ctx, + dir, + args, + docker.Streams{Stdout: writer, Stderr: io.MultiWriter(writer, stderr)}, + ) if err != nil { log.Errorf( "docker container failed to start: error=%v, command=%s, args=%s, cwd=%s", @@ -431,6 +442,9 @@ func (d *dockerDriver) startContainer( strings.Join(args, " "), dir, ) + if detail := strings.TrimSpace(stderr.String()); detail != "" { + return fmt.Errorf("failed to start dev container: %w: %s", err, detail) + } return fmt.Errorf("failed to start dev container: %w", err) } return nil diff --git a/pkg/driver/docker/lifecycle_test.go b/pkg/driver/docker/lifecycle_test.go index bbfd37626c..8eef4eba3e 100644 --- a/pkg/driver/docker/lifecycle_test.go +++ b/pkg/driver/docker/lifecycle_test.go @@ -1,17 +1,24 @@ package docker import ( + "bytes" "context" + "errors" "fmt" "os" + "os/exec" "path/filepath" + "runtime" "strconv" + "strings" + "sync" "testing" "time" "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/docker" "github.com/devsy-org/devsy/pkg/driver" + "github.com/devsy-org/devsy/pkg/subprocess" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -557,3 +564,85 @@ func TestIsTerminalOrContextError(t *testing.T) { assert.True(t, isTerminalOrContextError(fmt.Errorf("wrapped: %w", context.Canceled))) assert.True(t, isTerminalOrContextError(fmt.Errorf("wrapped: %w", context.DeadlineExceeded))) } + +func TestStartContainerPreservesEngineError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell") + } + source := filepath.Join(t.TempDir(), "missing.sock") + bin := filepath.Join(t.TempDir(), "engine") + require.NoError(t, os.WriteFile(bin, []byte(`#!/bin/sh +if [ "$1" = run ]; then + printf '%s\n' "bind source path does not exist: $2" "$ENGINE_TOKEN" >&2 + exit 17 +fi +`), 0o600)) + // #nosec G302 -- only the test owner can execute this temporary fake engine. + require.NoError(t, os.Chmod(bin, 0o700)) + d := &dockerDriver{ + Docker: &docker.DockerHelper{ + DockerCommand: bin, + Environment: []string{"ENGINE_TOKEN=private-engine-token"}, + }, + } + var streamed engineOutputBuffer + err := d.startContainer( + context.Background(), + t.TempDir(), + []string{testRunArg, source}, + &streamed, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "failed to start dev container") + assert.Contains(t, err.Error(), "bind source path does not exist: "+source) + assert.NotContains(t, err.Error(), "private-engine-token") + assert.Contains(t, streamed.String(), "bind source path does not exist: "+source) + var exitErr *exec.ExitError + require.True(t, errors.As(err, &exitErr)) + assert.Equal(t, 17, exitErr.ExitCode()) +} + +func TestStartContainerBoundsEngineError(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("requires a POSIX shell") + } + bin := filepath.Join(t.TempDir(), "engine") + require.NoError(t, os.WriteFile(bin, []byte(`#!/bin/sh +if [ "$1" = run ]; then + i=0 + while [ "$i" -lt 2048 ]; do + printf '%s' "`+strings.Repeat("x", 1024)+`" >&2 + i=$((i + 1)) + done + printf '%s' tail-marker >&2 + exit 1 +fi +`), 0o600)) + // #nosec G302 -- only the test owner can execute this temporary fake engine. + require.NoError(t, os.Chmod(bin, 0o700)) + d := &dockerDriver{Docker: &docker.DockerHelper{DockerCommand: bin}} + var streamed engineOutputBuffer + err := d.startContainer(context.Background(), t.TempDir(), []string{testRunArg}, &streamed) + require.Error(t, err) + assert.Len(t, streamed.String(), 2048*1024+len("tail-marker")) + assert.True(t, strings.HasSuffix(err.Error(), "tail-marker")) + assert.LessOrEqual(t, len(err.Error()), subprocess.MaxCapturedBytes+100) + assert.Contains(t, err.Error(), strings.Repeat("x", 100)) +} + +type engineOutputBuffer struct { + mu sync.Mutex + data bytes.Buffer +} + +func (b *engineOutputBuffer) Write(p []byte) (int, error) { + b.mu.Lock() + defer b.mu.Unlock() + return b.data.Write(p) +} + +func (b *engineOutputBuffer) String() string { + b.mu.Lock() + defer b.mu.Unlock() + return b.data.String() +} diff --git a/pkg/driver/docker/runargs.go b/pkg/driver/docker/runargs.go index e2454b1c22..cb8896f9f1 100644 --- a/pkg/driver/docker/runargs.go +++ b/pkg/driver/docker/runargs.go @@ -117,11 +117,7 @@ func (b *runArgsBuilder) addSELinux() error { } func (b *runArgsBuilder) addMounts() error { - args, err := b.driver.addMountArgs(b.args, b.params.Options) - if err != nil { - return err - } - b.args = args + b.args = b.driver.addMountArgs(b.args, b.params.Options) return nil } @@ -301,16 +297,11 @@ func (d *dockerDriver) addCapabilityArgs(args []string, options *driver.RunOptio return args } -func (d *dockerDriver) addMountArgs(args []string, options *driver.RunOptions) ([]string, error) { +func (d *dockerDriver) addMountArgs(args []string, options *driver.RunOptions) []string { for _, mount := range options.Mounts { - if mount.Type == "bind" && mount.Source != "" { - if _, err := os.Stat(mount.Source); os.IsNotExist(err) { - return nil, fmt.Errorf("bind mount source path does not exist %s", mount.Source) - } - } args = append(args, "--mount", mount.String()) } - return args, nil + return args } // ideVolumeProviders maps each supported JetBrains IDE to its server diff --git a/pkg/driver/docker/runargs_test.go b/pkg/driver/docker/runargs_test.go index 9c8f65bcda..4a7b25c236 100644 --- a/pkg/driver/docker/runargs_test.go +++ b/pkg/driver/docker/runargs_test.go @@ -1,10 +1,18 @@ package docker import ( + "os" + "path/filepath" + "github.com/devsy-org/devsy/pkg/devcontainer/config" "github.com/devsy-org/devsy/pkg/driver" ) +const ( + testBindMountType = "bind" + testReadonly = "readonly" +) + func (s *DockerDriverTestSuite) TestAddCapabilityArgs_SingleSecurityOpt() { opts := &driver.RunOptions{SecurityOpt: []string{testSeccompUnconfined}} args := s.driver.addCapabilityArgs(nil, opts) @@ -147,3 +155,124 @@ func (s *DockerDriverTestSuite) TestAddRunPlatform_ExplicitInConfigNotDuplicated } s.Equal(0, count, "should not auto-add when config already sets --platform") } + +func (s *DockerDriverTestSuite) TestAddMountArgs_EngineOnlyBindSource() { + source := filepath.Join(s.T().TempDir(), "engine-only.sock") + opts := &driver.RunOptions{ + Mounts: []*config.Mount{ + { + Type: testBindMountType, + Source: source, + Target: "/run/engine.sock", + Other: []string{testReadonly}, + }, + }, + } + args := s.driver.addMountArgs([]string{testRunArg}, opts) + s.Equal( + []string{ + testRunArg, + mountFlag, + "type=bind,src=" + source + ",dst=/run/engine.sock,readonly", + }, + args, + ) + _, err := os.Lstat(source) + s.True(os.IsNotExist(err)) +} + +func (s *DockerDriverTestSuite) TestAddMountArgs_PreservesAllMountTypesAndOrder() { + existing := s.T().TempDir() + absent := filepath.Join(existing, "absent") + opts := &driver.RunOptions{Mounts: []*config.Mount{ + { + Type: testBindMountType, + Source: existing, + Target: "/existing", + Other: []string{testReadonly, "bind-propagation=rslave"}, + }, + {Type: testBindMountType, Source: absent, Target: "/absent"}, + {Type: "volume", Source: "data", Target: "/data", Other: []string{"volume-nocopy"}}, + {Type: "tmpfs", Target: "/tmp", Other: []string{"tmpfs-size=4096"}}, + }} + args := s.driver.addMountArgs([]string{testRunArg}, opts) + s.Equal([]string{ + testRunArg, + mountFlag, "type=bind,src=" + existing + ",dst=/existing,readonly,bind-propagation=rslave", + mountFlag, "type=bind,src=" + absent + ",dst=/absent", + mountFlag, "type=volume,src=data,dst=/data,volume-nocopy", + mountFlag, "type=tmpfs,dst=/tmp,tmpfs-size=4096", + }, args) +} + +func (s *DockerDriverTestSuite) TestAddMountArgs_DoesNotModifySourceOrOptions() { + opts := &driver.RunOptions{Mounts: []*config.Mount{ + { + Type: testBindMountType, + Source: filepath.Join(s.T().TempDir(), "engine.sock"), + Target: "/socket", + Other: []string{testReadonly, "consistency=cached", "bind-propagation=rprivate"}, + }, + { + Type: testBindMountType, + Source: "./engine/../sock", + Target: "/relative", + Other: []string{testReadonly}, + }, + { + Type: "volume", + Source: "data", + Target: "/data", + External: true, + Other: []string{"volume-nocopy"}, + }, + {Type: "tmpfs", Target: "/tmp", Other: []string{"tmpfs-size=4096"}}, + }} + before := make([]config.Mount, len(opts.Mounts)) + formatted := make([]string, len(opts.Mounts)) + pointers := append([]*config.Mount(nil), opts.Mounts...) + expected := []string{testRunArg} + for i, mount := range opts.Mounts { + before[i] = *mount + before[i].Other = append([]string(nil), mount.Other...) + formatted[i] = mount.String() + expected = append(expected, mountFlag, formatted[i]) + } + args := s.driver.addMountArgs([]string{testRunArg}, opts) + s.Equal(expected, args) + for i, mount := range opts.Mounts { + s.Equal(before[i], *mount) + s.Equal(formatted[i], mount.String()) + s.Same(pointers[i], mount) + } +} + +func (s *DockerDriverTestSuite) TestRunArgsBuilder_AdditionalMountReachesArgs() { + source := filepath.Join(s.T().TempDir(), "engine.sock") + b := &runArgsBuilder{ + args: []string{testRunArg, "--init"}, + driver: s.driver, + params: &driver.RunImageDevContainerParams{ + Options: &driver.RunOptions{ + Mounts: []*config.Mount{ + { + Type: testBindMountType, + Source: source, + Target: "/socket", + Other: []string{testReadonly}, + }, + }, + }, + }, + } + s.Require().NoError(b.addMounts()) + s.Equal( + []string{ + testRunArg, + "--init", + mountFlag, + "type=bind,src=" + source + ",dst=/socket,readonly", + }, + b.args, + ) +} diff --git a/sites/docs-devsy-sh/content/docs/tutorials/podman-provider-setup.mdx b/sites/docs-devsy-sh/content/docs/tutorials/podman-provider-setup.mdx index d65cb4b93c..ad8cfc7c56 100644 --- a/sites/docs-devsy-sh/content/docs/tutorials/podman-provider-setup.mdx +++ b/sites/docs-devsy-sh/content/docs/tutorials/podman-provider-setup.mdx @@ -128,6 +128,28 @@ If the socket path is not the default, set it: devsy provider set podman --option PODMAN_HOST=unix:///run/user/$(id -u)/podman/podman.sock ``` +### Bind mounts from the Podman machine + +On macOS and Windows with remote Podman, additional bind mount sources are paths in the selected engine's filesystem. A host directory shared into the machine is valid when that share is configured and accessible there. Devsy passes these mounts to the engine for source validation; a path existing on your desktop alone does not establish that it exists in the machine. See [Podman's volume documentation](https://docs.podman.io/en/stable/markdown/podman-run.1.html#volume-v-source-volume-host-dir-container-dir-options). + +Check the connection, machine, and actual guest source, replacing `my-machine` and `/path/in/machine`: + +```bash +podman system connection list +podman machine inspect my-machine +podman machine ssh my-machine id -u +podman machine ssh my-machine stat /path/in/machine +``` + +The machine's rootless UID need not match your macOS or Windows host identity. For a rootless socket source, use the guest UID to check `/run/user//podman/podman.sock`; the rootful socket is normally `/run/podman/podman.sock`. + +- **Source missing in the guest:** correct the source path or configure the intended host share into the machine. +- **Wrong connection or mode:** ensure the native Podman connection and Devsy's `PODMAN_HOST` select the same machine and rootless/rootful service. +- **Source exists but access fails:** check filesystem permissions for the engine user and applicable SELinux policy. Avoid broad permission changes or relabeling shared system paths. +- **Socket absent inside the workspace container:** connecting Devsy to the engine does not mount its socket into the container. If the workspace needs engine access, explicitly configure a bind mount from the verified guest socket to the path its tools expect. + +Sharing the socket grants the container [control of the engine as the service user](https://docs.podman.io/en/stable/markdown/podman-system-service.1.html#security). Devsy does not add this mount by default; configure it only for a workspace you trust. + ## Known differences ### Dockerfile builds