From 9d1f758418887128f0497b6a6249083e24ea1827 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Mon, 19 Oct 2026 21:39:35 -0600 Subject: [PATCH 1/5] fix(docker): defer bind mount validation to the engine --- .../up/provider_podman_machine_bind_darwin.go | 279 ++++++++++++++++++ e2e/tests/up/up_behaviors.go | 21 +- pkg/driver/docker/diagnostics.go | 6 +- pkg/driver/docker/lifecycle.go | 16 +- pkg/driver/docker/lifecycle_test.go | 89 ++++++ pkg/driver/docker/runargs.go | 15 +- pkg/driver/docker/runargs_test.go | 129 ++++++++ .../docs/tutorials/podman-provider-setup.mdx | 22 ++ 8 files changed, 559 insertions(+), 18 deletions(-) create mode 100644 e2e/tests/up/provider_podman_machine_bind_darwin.go 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..d6ad033d1b --- /dev/null +++ b/e2e/tests/up/provider_podman_machine_bind_darwin.go @@ -0,0 +1,279 @@ +package up + +import ( + "context" + "encoding/json" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "time" + + "github.com/devsy-org/devsy/e2e/framework" + "github.com/onsi/ginkgo/v2" + "github.com/onsi/gomega" +) + +const ( + 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 } + 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("up-provider-podman-machine-bind"), 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") + framework.ExpectNoError( + err, + "native gate requires an installed Podman CLI and running machine", + ) + 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", + } { + gomega.Expect(os.Getenv(key)). + To(gomega.BeEmpty(), "%s would override the isolated provider or selected connection", key) + } + 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 + } + } + gomega.Expect(selected). + To(gomega.BeNumerically(">=", 0), "a default native connection is required") + 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) + } + gomega.Expect(machineNames).NotTo(gomega.BeEmpty()) + framework.ExpectNoError(json.Unmarshal([]byte(run(inspectArgs...)), &machines)) + machine := "" + for _, candidate := range machines { + if candidate.State == "running" && + 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") + machine = candidate.Name + } + } + gomega.Expect(machine). + NotTo(gomega.BeEmpty(), "selected connection must match a running machine's SSH port and user") + gomega.Expect(endpoint.Hostname()). + To(gomega.BeElementOf("127.0.0.1", "localhost", "::1")) + guest := func(script string) string { return run(podmanMachineCommand, cmdSSH, machine, "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(info.Host.RemoteSocket.Path)) + 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, podmanMachineCommand, cmdSSH, machine, + "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, + ), + ) + _, 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)) + before := guest( + "stat -c '%a:%u:%g:%s:%Y' " + quote( + source, + ) + " " + quote( + source+podmanMarkerName, + ), + ) + + initialDir, err := os.Getwd() + framework.ExpectNoError(err) + devsyHome, err := os.MkdirTemp("", "devsy-machine-bind-home-") + 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("stat -c '%a:%u:%g:%s:%Y' " + quote(source) + " " + quote(source+podmanMarkerName))). + 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()), + ) + }) 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 From c2a22dcfa7260f7445cd855c829605b9336f2ce3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 20 Oct 2026 00:36:01 -0600 Subject: [PATCH 2/5] fix(e2e): distinguish missing Podman prerequisites from misconfiguration --- .../up/provider_podman_machine_bind_darwin.go | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/e2e/tests/up/provider_podman_machine_bind_darwin.go b/e2e/tests/up/provider_podman_machine_bind_darwin.go index d6ad033d1b..ef5bd16f5c 100644 --- a/e2e/tests/up/provider_podman_machine_bind_darwin.go +++ b/e2e/tests/up/provider_podman_machine_bind_darwin.go @@ -17,12 +17,13 @@ import ( ) const ( - podmanMachineCommand = "machine" - podmanJSONFormat = "json" - podmanFormatFlag = "--format" - podmanMarkerTarget = "/tmp/devsy-engine-only-marker" - podmanMarkerName = "/marker" - podmanHomeEnv = "DEVSY_HOME" + 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 { @@ -60,16 +61,13 @@ type podmanBindFixture struct { } var _ = ginkgo.Describe("Podman Machine engine-side bind sources", - ginkgo.Label("up-provider-podman-machine-bind"), func() { + 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") - framework.ExpectNoError( - err, - "native gate requires an installed Podman CLI and running machine", - ) + 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() @@ -79,8 +77,8 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", for _, key := range []string{ "CONTAINER_HOST", "CONTAINER_CONNECTION", "DOCKER_HOST", "DEVSY_CONFIG", "DEVSY_PROVIDER", } { - gomega.Expect(os.Getenv(key)). - To(gomega.BeEmpty(), "%s would override the isolated provider or selected connection", key) + requirePodmanMachinePrerequisite(os.Getenv(key) == "", + key+" overrides the isolated provider or selected connection") } var connections []podmanMachineConnection framework.ExpectNoError( @@ -97,8 +95,10 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", selected = i } } - gomega.Expect(selected). - To(gomega.BeNumerically(">=", 0), "a default native connection is required") + requirePodmanMachinePrerequisite( + selected >= 0, + "no default native Podman connection is configured", + ) connection := connections[selected] endpoint, err := url.Parse(connection.URI) framework.ExpectNoError(err) @@ -119,20 +119,24 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", for _, entry := range machineNames { inspectArgs = append(inspectArgs, entry.Name) } - gomega.Expect(machineNames).NotTo(gomega.BeEmpty()) + requirePodmanMachinePrerequisite( + len(machineNames) > 0, + "no Podman machines are available", + ) framework.ExpectNoError(json.Unmarshal([]byte(run(inspectArgs...)), &machines)) machine := "" for _, candidate := range machines { - if candidate.State == "running" && - fmt.Sprint(candidate.SSHConfig.Port) == endpoint.Port() && + 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 running machine's SSH port and user") + 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")) guest := func(script string) string { return run(podmanMachineCommand, cmdSSH, machine, "sh -c "+quote(script)) } @@ -277,3 +281,15 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", ginkgo.SpecTimeout(framework.TimeoutLong()), ) }) + +func requirePodmanMachinePrerequisite(available bool, reason string) { + if available { + return + } + config, _ := ginkgo.GinkgoConfiguration() + message := "native gate BLOCKED: " + reason + if strings.TrimSpace(config.LabelFilter) == podmanMachineBindLabel { + ginkgo.Fail(message, 1) + } + ginkgo.Skip(message, 1) +} From aacdbb489d09b72a764c0df5ed0a1d021690b0ed Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 20 Oct 2026 03:32:58 -0600 Subject: [PATCH 3/5] fix(e2e): fail selected native runs with missing prerequisites --- e2e/tests/up/provider_podman_machine_bind_darwin.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/e2e/tests/up/provider_podman_machine_bind_darwin.go b/e2e/tests/up/provider_podman_machine_bind_darwin.go index ef5bd16f5c..75d4fc3c60 100644 --- a/e2e/tests/up/provider_podman_machine_bind_darwin.go +++ b/e2e/tests/up/provider_podman_machine_bind_darwin.go @@ -288,7 +288,8 @@ func requirePodmanMachinePrerequisite(available bool, reason string) { } config, _ := ginkgo.GinkgoConfiguration() message := "native gate BLOCKED: " + reason - if strings.TrimSpace(config.LabelFilter) == podmanMachineBindLabel { + // Ginkgo has already matched the full label expression before this spec runs. + if strings.TrimSpace(config.LabelFilter) != "" { ginkgo.Fail(message, 1) } ginkgo.Skip(message, 1) From a48296e8d185eaa7c1e6cc5f611ca920d64f18a3 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Tue, 20 Oct 2026 05:24:32 -0600 Subject: [PATCH 4/5] test: distinguish native Podman qualification selectors --- .../up/provider_podman_machine_bind_darwin.go | 12 +++- ...rovider_podman_machine_bind_darwin_test.go | 64 +++++++++++++++++++ 2 files changed, 74 insertions(+), 2 deletions(-) create mode 100644 e2e/tests/up/provider_podman_machine_bind_darwin_test.go diff --git a/e2e/tests/up/provider_podman_machine_bind_darwin.go b/e2e/tests/up/provider_podman_machine_bind_darwin.go index 75d4fc3c60..1510bc946d 100644 --- a/e2e/tests/up/provider_podman_machine_bind_darwin.go +++ b/e2e/tests/up/provider_podman_machine_bind_darwin.go @@ -10,6 +10,7 @@ import ( "path/filepath" "strings" "time" + "unicode" "github.com/devsy-org/devsy/e2e/framework" "github.com/onsi/ginkgo/v2" @@ -288,9 +289,16 @@ func requirePodmanMachinePrerequisite(available bool, reason string) { } config, _ := ginkgo.GinkgoConfiguration() message := "native gate BLOCKED: " + reason - // Ginkgo has already matched the full label expression before this spec runs. - if strings.TrimSpace(config.LabelFilter) != "" { + 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) + } + }) + } +} From a604663fc2c04d0daad931f90737a0c75119e2d8 Mon Sep 17 00:00:00 2001 From: Samuel K Date: Wed, 21 Oct 2026 10:06:02 -0600 Subject: [PATCH 5/5] test: qualify native Podman bind fixture on macOS --- .../up/provider_podman_machine_bind_darwin.go | 51 +++++++++++++------ 1 file changed, 35 insertions(+), 16 deletions(-) diff --git a/e2e/tests/up/provider_podman_machine_bind_darwin.go b/e2e/tests/up/provider_podman_machine_bind_darwin.go index 1510bc946d..d245d3c3d7 100644 --- a/e2e/tests/up/provider_podman_machine_bind_darwin.go +++ b/e2e/tests/up/provider_podman_machine_bind_darwin.go @@ -46,9 +46,12 @@ type podmanMachineInspection struct { type ( podmanMachineName struct{ Name string } - podmanMachineSecurity struct{ Rootless bool } - podmanMachineSocket struct{ Path string } - podmanMachineHost struct { + podmanMachineSecurity struct { + Rootless bool + SELinuxEnabled bool + } + podmanMachineSocket struct{ Path string } + podmanMachineHost struct { Security podmanMachineSecurity Hostname string RemoteSocket podmanMachineSocket @@ -140,7 +143,18 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", 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")) - guest := func(script string) string { return run(podmanMachineCommand, cmdSSH, machine, "sh -c "+quote(script)) } + + // 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), @@ -155,7 +169,8 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", guest(`printf %s "$XDG_RUNTIME_DIR"`), ) gomega.Expect(info.Host.Hostname).To(gomega.Equal(guest("hostname"))) - gomega.Expect(endpoint.Path).To(gomega.Equal(info.Host.RemoteSocket.Path)) + 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", @@ -174,8 +189,8 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", 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, podmanMachineCommand, cmdSSH, machine, - "sh -c "+quote("rm -rf -- "+quote(source))). + 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) }) @@ -191,22 +206,26 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", 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)) - before := guest( - "stat -c '%a:%u:%g:%s:%Y' " + quote( - source, - ) + " " + quote( - source+podmanMarkerName, - ), - ) + snapshotCommand := "stat -c " + quote(snapshotFormat) + " " + quote(source) + " " + + quote(source+podmanMarkerName) + before := guest(snapshotCommand) initialDir, err := os.Getwd() framework.ExpectNoError(err) - devsyHome, err := os.MkdirTemp("", "devsy-machine-bind-home-") + // 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) @@ -246,7 +265,7 @@ var _ = ginkgo.Describe("Podman Machine engine-side bind sources", framework.ExpectNoError(f.DevsyWorkspaceDelete(ctx, workspace)) gomega.Expect(guest("cat " + quote(source+podmanMarkerName))). To(gomega.Equal(marker)) - gomega.Expect(guest("stat -c '%a:%u:%g:%s:%Y' " + quote(source) + " " + quote(source+podmanMarkerName))). + gomega.Expect(guest(snapshotCommand)). To(gomega.Equal(before)) _, err = os.Stat(source + podmanMarkerName) gomega.Expect(os.IsNotExist(err)).To(gomega.BeTrue())